Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 181 additions & 34 deletions packages/forest_admin_datasource_intercom/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ class Client # rubocop:disable Metrics/ClassLength
# callers never have to know how the absence is spelled on the wire.
# `total_count` is exact, filter included, which is what makes Forest's
# record counter and its "number of" charts one request each.
Page = Struct.new(:records, :next_cursor, :total_count, keyword_init: true)
# `total_pages` is filled by the one endpoint that paginates by offset and
# nil everywhere else: a cursor page has no notion of how many there are.
Page = Struct.new(:records, :next_cursor, :total_count, :total_pages, keyword_init: true)

def initialize(configuration)
@configuration = configuration
Expand Down Expand Up @@ -65,14 +67,44 @@ def list_page(path, per_page:, starting_after: nil, params: {}, list_key: 'data'
# translator wrote, and it travels in the body; `params` is what still
# belongs in the query string -- `display_as` above all, which is not part
# of the search payload.
def search_page(path, query:, per_page:, starting_after: nil, list_key: 'data', params: {})
# `sort` is honoured by `/contacts/search` alone. The other two search
# endpoints accept one and ignore it without a word -- measured -- so the
# collections that read them never send one, and the parameter is here for
# the one that does. `{ field:, ascending: }`, translated to Intercom's own
# spelling on the way out.
def search_page(path, query:, per_page:, starting_after: nil, list_key: 'data', params: {}, sort: nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 7): search_page [qlty:function-parameters]

pagination = { 'per_page' => self.class.bounded_per_page(per_page) }
pagination['starting_after'] = starting_after unless blank?(starting_after)
body = { 'query' => query, 'pagination' => pagination }
body['sort'] = sort_clause(sort) if sort

must_succeed(path) { to_page(post(path, body, params: params).body, path, list_key) }
end

# One page of an endpoint that paginates by **offset** rather than by
# cursor. `POST /companies/list` is the only one, and it is what lets the
# Companies collection answer page 7 of a list view with one request
# instead of walking six pages to reach it. Intercom counts pages from 1.
def offset_page(path, page:, per_page:, params: {}, list_key: 'data')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): offset_page [qlty:function-parameters]

query = params.merge('page' => [page.to_i, 1].max,
'per_page' => self.class.bounded_per_page(per_page))

must_succeed(path) { to_page(post(path, {}, params: query).body, path, list_key) }
end

# An exact lookup, and the shape surprise that comes with it: `GET
# /companies?name=` answers the company itself where `?tag_id=` answers a
# list. A record is read here as a page of one, so a caller writes one route
# rather than testing the envelope.
def lookup_page(path, params:, list_key: 'data')
must_succeed(path) do
body = get(path, params).body
next Page.new(records: [body], next_cursor: nil, total_count: 1) if single_record?(body, list_key)

to_page(body, path, list_key)
end
end

# One record from its own endpoint. Raises on a 404 like on any other
# failure: what a missing record means -- a stale link, a record outside the
# token's scope, a deletion -- is the caller's to decide, not the client's.
Expand All @@ -98,8 +130,11 @@ def fetch_record(path, id, params: {}, boot: false)
# parameter in the specification is not a promise that a large workspace
# answers in one response, and a truncated reference collection would show
# an operator a state list missing its last states.
def fetch_all(path, list_key: 'data', boot: false)
must_succeed(path) { collect_pages(path, list_key: list_key, boot: boot) }
# `params` is what narrows the endpoint rather than what pages it:
# `/data_attributes` answers the attributes of contacts and those of
# companies under `?model=`, and both are read whole.
def fetch_all(path, list_key: 'data', params: {}, boot: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): fetch_all [qlty:function-parameters]

must_succeed(path) { collect_pages(path, list_key: list_key, params: params, boot: boot) }
end

# The page size Intercom accepts, whatever was asked for.
Expand Down Expand Up @@ -149,13 +184,28 @@ def verify_pinned_version(response)
)
end

def collect_pages(path, list_key:, boot:)
# Intercom spells an order `{ "field": "...", "order": "descending" }`,
# and answers `data_invalid` on anything else -- so the clause is written
# here rather than by the caller, whose vocabulary is Forest's.
def sort_clause(sort)
{ 'field' => sort[:field].to_s, 'order' => sort[:ascending] == false ? 'descending' : 'ascending' }
end

# A record rather than a listing: no list under either key, and an id where
# a record carries one. An empty listing is not one of these -- it answers
# `data` as an empty array, which is a page of nothing rather than a record.
def single_record?(body, list_key)
body.is_a?(Hash) && !body[list_key].is_a?(Array) && !body['data'].is_a?(Array) && body.key?('id')
end

def collect_pages(path, list_key:, params:, boot:)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): collect_pages [qlty:function-parameters]

records = []
cursor = nil
pages = 0

loop do
body = get(path, cursor.nil? ? nil : { 'starting_after' => cursor }, boot: boot).body
query = cursor.nil? ? params : params.merge('starting_after' => cursor)
body = get(path, query.empty? ? nil : query, boot: boot).body
records.concat(extract_entities(body, path, list_key))
pages += 1
cursor = next_cursor(body, path)
Expand Down Expand Up @@ -199,7 +249,8 @@ def log_collection_cap(path, pages, collected)
def to_page(body, operation, list_key)
Page.new(records: extract_entities(body, operation, list_key),
next_cursor: next_cursor(body, operation),
total_count: extract_count(body))
total_count: extract_count(body),
total_pages: extract_total_pages(body))
end

# Absent on the last page, which is how the walk knows it is done. An older
Expand Down Expand Up @@ -236,6 +287,14 @@ def extract_count(body)
count.is_a?(Numeric) ? count.to_i : nil
end

# How many pages the offset tier has to read through, when the endpoint
# counts them. nil on a cursor page, which counts nothing.
def extract_total_pages(body)
pages = body['pages'] if body.is_a?(Hash)
total = pages['total_pages'] if pages.is_a?(Hash)
total.is_a?(Numeric) ? total.to_i : nil
end

def refuse_body_shape(operation, detail)
raise APIError.new("Intercom API call failed: #{operation}: unexpected response shape, #{detail}", status: nil)
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
module ForestAdminDatasourceIntercom
module Collections
# The accounts the contacts belong to.
#
# The one collection Intercom paginates by offset, and the one place R1 --
# a window the API cannot express -- does not apply: `POST /companies/list`
# takes a page number, which is what a list view asks for. See
# `OffsetCollection`.
#
# In exchange it is the least filterable collection of the datasource.
# There is no `/companies/search`, and what `GET /companies` answers is four
# exact lookups: by `name`, by `company_id` -- the workspace's own
# identifier, not Intercom's -- by `tag_id` and by `segment_id`. Two of them
# are published as filters here, the two that name a column of this
# collection; a tag and a segment are collections of their own and arrive
# with lot 5, which is where filtering by them belongs.
#
# `GET /companies/scroll` is deliberately rejected rather than used: one
# open scroll per application, expiring after a minute, cannot serve two
# operators looking at a list at the same time.
class Company < OffsetCollection
include Company::Serializer
include CustomAttributes

# The column each lookup is written on, and the query parameter Intercom
# answers it under. They happen to share a name; keeping the mapping
# explicit is what lets a column be renamed without silently dropping the
# lookup.
LOOKUPS = { 'name' => 'name', 'company_id' => 'company_id' }.freeze

def initialize(datasource, attributes: [])
@attributes = attributes
super(datasource, 'IntercomCompany')
end

protected

def list_path = 'companies/list'
def record_endpoint = 'companies'
def lookups = LOOKUPS

private

def define_schema
add_column('id', 'String', is_primary_key: true)
# Intercom's id and the workspace's own are two different things, and an
# ops team knows the second one: it is what their billing system calls
# the account.
add_column('company_id', 'String')
add_column('name', 'String')
define_profile_columns
define_activity_columns
# Before the attribute columns, so an attribute whose name lands on the
# relation is skipped with a warning rather than taking the boot with
# it.
add_one_to_many('contacts', foreign_collection: 'IntercomContact', origin_key: 'company_id')
register_attribute_columns
end

def define_profile_columns
add_column('plan_name', 'String')
add_column('size', 'Number')
add_column('industry', 'String')
add_column('website', 'String')
add_column('monthly_spend', 'Number')
end

def define_activity_columns
add_column('user_count', 'Number')
add_column('session_count', 'Number')
add_column('created_at', 'Date')
add_column('updated_at', 'Date')
add_column('last_request_at', 'Date')
# When the account was created in the customer's own system, which is
# not when Intercom heard about it.
add_column('remote_created_at', 'Date')
end

# Typed from `GET /data_attributes?model=company`, and unfilterable for
# the same reason as everything else here: this collection is looked up,
# not searched.
def attribute_kind = 'company'
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module ForestAdminDatasourceIntercom
module Collections
class Company < OffsetCollection
# One Intercom company flattened into the row the schema declares.
module Serializer
protected

def serialize(company)
attrs = company.is_a?(Hash) ? company : {}
plan = attrs['plan'].is_a?(Hash) ? attrs['plan'] : {}

identity(attrs).merge(
'plan_name' => plan['name'],
'user_count' => attrs['user_count'],
'session_count' => attrs['session_count']
).merge(dates_of(attrs)).merge(attribute_values(attrs['custom_attributes']))
end

private

def identity(attrs)
{
'id' => stringify_id(attrs['id']),
'company_id' => stringify_id(attrs['company_id']),
'name' => attrs['name'],
'size' => attrs['size'],
'industry' => attrs['industry'],
'website' => attrs['website'],
'monthly_spend' => attrs['monthly_spend']
}
end

def dates_of(attrs)
{
'created_at' => stamp(attrs['created_at']),
'updated_at' => stamp(attrs['updated_at']),
'last_request_at' => stamp(attrs['last_request_at']),
'remote_created_at' => stamp(attrs['remote_created_at'])
}
end
end
end
end
end
Loading
Loading