-
Notifications
You must be signed in to change notification settings - Fork 1
feat(datasource intercom): contacts, companies and the promotion of the relations (lot 4) #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/datasource-intercom
Are you sure you want to change the base?
Changes from all commits
13f61f0
642c2ff
6667c23
16695cd
6880e2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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) | ||
| 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') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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. | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| must_succeed(path) { collect_pages(path, list_key: list_key, params: params, boot: boot) } | ||
| end | ||
|
|
||
| # The page size Intercom accepts, whatever was asked for. | ||
|
|
@@ -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:) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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) | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
| 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 |
There was a problem hiding this comment.
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]