-
Notifications
You must be signed in to change notification settings - Fork 1
feat(datasource-pylon): all collections + relations (EXT-8) #354
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-pylon
Are you sure you want to change the base?
Changes from all commits
366bbdf
1fba1a8
8f5a0c2
ad725d5
e3a0366
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| module ForestAdminDatasourcePylon | ||
| class Client | ||
| # Long by line count only: the public surface is one explicit method per Pylon | ||
| # endpoint, each delegating to the shared helpers below. | ||
| class Client # rubocop:disable Metrics/ClassLength | ||
| MAX_SEARCH_LIMIT = 1000 | ||
|
|
||
| # `next_cursor` is nil as soon as Pylon stops advertising a next page, so | ||
|
|
@@ -19,22 +21,90 @@ def me | |
| # POST /issues/search accepts an empty body and then returns the most recent | ||
| # issues, ordered by `created_at` descending. | ||
| def search_issues(limit:, cursor: nil, filter: nil, search_text: nil) | ||
| search_resource('issues/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text) | ||
| end | ||
|
|
||
| # Accepts either the UUID or the issue number. | ||
| def fetch_issue(id) | ||
| fetch_resource('issues', id) | ||
| end | ||
|
|
||
| def search_accounts(limit:, cursor: nil, filter: nil, search_text: nil) | ||
| search_resource('accounts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text) | ||
| end | ||
|
|
||
| def list_accounts(limit:, cursor: nil) | ||
| list_resource('accounts', limit: limit, cursor: cursor) | ||
| end | ||
|
|
||
| # Accepts either the Pylon UUID or the account's external id. | ||
| def fetch_account(id) | ||
| fetch_resource('accounts', id) | ||
| end | ||
|
|
||
| def search_contacts(limit:, cursor: nil, filter: nil, search_text: nil) | ||
|
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. |
||
| search_resource('contacts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text) | ||
| end | ||
|
|
||
| # GET /contacts is paginated exactly like GET /accounts even though the | ||
| # OpenAPI spec forgets to document its query parameters. | ||
| def list_contacts(limit:, cursor: nil) | ||
| list_resource('contacts', limit: limit, cursor: cursor) | ||
| end | ||
|
|
||
| def fetch_contact(id) | ||
| fetch_resource('contacts', id) | ||
| end | ||
|
|
||
| # GET /users is unpaginated. Deactivated agents are included by default so | ||
| # that assignees of older issues stay resolvable. | ||
| def fetch_users(include_deactivated: true) | ||
| fetch_all('users', 'include_deactivated' => include_deactivated) | ||
| end | ||
|
|
||
| def fetch_user(id) | ||
| fetch_resource('users', id) | ||
| end | ||
|
|
||
| # GET /teams is unpaginated and takes no parameter. | ||
| def fetch_teams | ||
| fetch_all('teams') | ||
| end | ||
|
|
||
| def fetch_team(id) | ||
| fetch_resource('teams', id) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def search_resource(path, limit:, cursor: nil, filter: nil, search_text: nil) | ||
|
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. |
||
| body = { 'limit' => clamp_limit(limit) } | ||
| body['cursor'] = cursor unless blank?(cursor) | ||
| body['filter'] = filter unless filter.nil? | ||
| body['search_text'] = search_text unless blank?(search_text) | ||
|
|
||
| must_succeed('issues/search') { to_search_page(connection.post('issues/search', body).body) } | ||
| must_succeed(path) { to_search_page(connection.post(path, body).body) } | ||
| end | ||
|
|
||
| # Accepts either the UUID or the issue number. The id comes from | ||
| # operator-supplied filter values, so it is escaped before joining the path. | ||
| def fetch_issue(id) | ||
| path = "issues/#{Faraday::Utils.escape(id)}" | ||
| must_succeed(path) { extract_data(connection.get(path).body) } | ||
| # `limit` is mandatory on the paginated GET endpoints, unlike their POST | ||
| # /search counterparts which default it server-side. | ||
| def list_resource(path, limit:, cursor: nil) | ||
| params = { 'limit' => clamp_limit(limit) } | ||
| params['cursor'] = cursor unless blank?(cursor) | ||
|
|
||
| must_succeed(path) { to_search_page(connection.get(path, params).body) } | ||
| end | ||
|
|
||
| private | ||
| def fetch_all(path, params = {}) | ||
| must_succeed(path) { Array(extract_data(connection.get(path, params).body)) } | ||
| end | ||
|
|
||
| # The id comes from operator-supplied filter values, so it is escaped before | ||
| # being joined to the path. | ||
| def fetch_resource(resource, id) | ||
| path = "#{resource}/#{Faraday::Utils.escape(id)}" | ||
| must_succeed(path) { extract_data(connection.get(path).body) } | ||
| end | ||
|
|
||
| def clamp_limit(limit) | ||
| value = limit.to_i | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| module ForestAdminDatasourcePylon | ||
| module Collections | ||
| class Account < CursorCollection | ||
| include SchemaDefinition | ||
| include Serializer | ||
|
|
||
| def initialize(datasource, custom_fields: []) | ||
| super(datasource, 'PylonAccount', custom_fields: custom_fields, searchable: true) | ||
| end | ||
|
|
||
| protected | ||
|
|
||
| def filter_table = ApiFilters | ||
|
|
||
| def unsortable_warning | ||
| '[forest_admin_datasource_pylon] PylonAccount cannot honour the requested order; neither GET /accounts ' \ | ||
| 'nor POST /accounts/search takes a sort parameter, so accounts come back in the order the API imposes.' | ||
| end | ||
|
|
||
| def search_page(limit:, cursor:, filter:, search_text:) | ||
|
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. |
||
| datasource.client.search_accounts(limit: limit, cursor: cursor, filter: filter, search_text: search_text) | ||
| end | ||
|
|
||
| def list_page(limit:, cursor:) | ||
| datasource.client.list_accounts(limit: limit, cursor: cursor) | ||
| end | ||
|
|
||
| def fetch_one(id) | ||
| datasource.client.fetch_account(id) | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
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. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| module ForestAdminDatasourcePylon | ||
| module Collections | ||
| class Account < CursorCollection | ||
| # The allow-list of `POST /accounts/search`, transcribed from the API | ||
| # reference: a field absent from this table cannot be filtered at all, and | ||
| # an operator absent from a field's map is rejected by Pylon. | ||
| # | ||
| # It is the single source of truth for filtering — `define_schema` derives | ||
| # every column's `filter_operators` from it, so the schema cannot | ||
| # advertise a filter the translator would then refuse. | ||
| module ApiFilters | ||
| Maps = Query::OperatorMaps | ||
|
|
||
| extend Maps::Table | ||
|
|
||
| CUSTOM_FIELD_OPS = Maps::CUSTOM_FIELD_OPS | ||
|
|
||
| # `id` is filtered server-side here, which is what spares this | ||
| # collection the primary-key short-circuit Issue needs. | ||
| # | ||
| # `name` gets SUBSTRING rather than FULL_TEXT: the endpoint accepts | ||
| # `string_contains` but no negation of it. `external_ids` is left out | ||
| # entirely although the endpoint filters it — the API matches the bare | ||
| # external-id strings while the column shows `{external_id, label}` | ||
| # objects, so the filter would run on something the operator cannot see. | ||
| # The account read endpoint accepts an external id in place of the | ||
| # primary key, which is the way to reach a record by one. | ||
| # | ||
| # No time field is filterable: `created_at`, `updated_at` and | ||
| # `latest_customer_activity_time` are absent from the allow-list. | ||
| API_FILTERS = { | ||
| 'id' => { ops: Maps::EQUALITY }, | ||
| 'name' => { ops: Maps::EQUALITY.merge(Maps::SUBSTRING) }, | ||
| 'domains' => { ops: Maps::MEMBERSHIP }, | ||
| 'tags' => { ops: Maps::MEMBERSHIP }, | ||
| 'owner_id' => { ops: Maps::EQUALITY.merge(Maps::PRESENCE) } | ||
| }.freeze | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| module ForestAdminDatasourcePylon | ||
| module Collections | ||
| class Account < CursorCollection | ||
| # Every column is read-only in this story: writes land in a later one. No | ||
| # column is sortable either — neither `GET /accounts` nor | ||
| # `POST /accounts/search` exposes a sort parameter, so advertising a | ||
| # sortable column would let the UI ask for an order the API cannot honour. | ||
| # | ||
| # Filter operators are not chosen here: they come from | ||
| # `ApiFilters::API_FILTERS`, which mirrors the allow-list of the API. A | ||
| # column missing from that table gets no operator, so the UI never offers | ||
| # a filter Pylon would refuse. | ||
| module SchemaDefinition | ||
| ColumnSchema = BaseCollection::ColumnSchema | ||
| OneToManySchema = BaseCollection::OneToManySchema | ||
|
|
||
| private | ||
|
|
||
| def define_schema | ||
| define_identity_fields | ||
| define_domain_fields | ||
| define_ownership_fields | ||
| define_integration_fields | ||
| define_time_fields | ||
| end | ||
|
|
||
| # The reverse sides of the two ManyToOne relations pointing here. Both | ||
| # `/issues/search` and `/contacts/search` filter `account_id` | ||
| # server-side, so a related list is one request and no in-memory pass. | ||
| # | ||
| # `owner_id` stays a plain column: it does point at a PylonUser, and the | ||
| # embedder would resolve it like any other key, but nothing in the panel | ||
| # asks for the owner of an account yet. | ||
| def define_relations | ||
| add_field('issues', OneToManySchema.new(foreign_collection: 'PylonIssue', | ||
| origin_key: 'account_id', origin_key_target: 'id')) | ||
| add_field('contacts', OneToManySchema.new(foreign_collection: 'PylonContact', | ||
| origin_key: 'account_id', origin_key_target: 'id')) | ||
| end | ||
|
|
||
| def define_identity_fields | ||
| add_field('id', ColumnSchema.new(column_type: 'String', | ||
| filter_operators: ApiFilters.forest_operators('id'), | ||
| is_primary_key: true, is_read_only: true)) | ||
| add_column('name', 'String') | ||
| # Left as String rather than Enum: Pylon ships customer / partner / | ||
| # prospect but lets an organization define its own account types. | ||
| add_column('type', 'String') | ||
| add_column('is_disabled', 'Boolean') | ||
| end | ||
|
|
||
| # `domain` and `primary_domain` carry the same value; both are kept | ||
| # because Pylon returns both, and only the `domains` list is filterable. | ||
| def define_domain_fields | ||
| add_column('domain', 'String') | ||
| add_column('primary_domain', 'String') | ||
| add_column('domains', 'Json') | ||
| add_column('tags', 'Json') | ||
| end | ||
|
|
||
| def define_ownership_fields | ||
| # Flattened from the nested `{ id: ..., email: ... }` object Pylon | ||
| # returns; a plain column, see `define_relations` above. | ||
| add_column('owner_id', 'String') | ||
| add_column('external_ids', 'Json') | ||
| end | ||
|
|
||
| def define_integration_fields | ||
| add_column('channels', 'Json') | ||
| add_column('crm_settings', 'Json') | ||
| end | ||
|
|
||
| def define_time_fields | ||
| %w[created_at updated_at latest_customer_activity_time].each { |field| add_column(field, 'Date') } | ||
| end | ||
|
|
||
| def add_column(name, type) | ||
| add_field(name, ColumnSchema.new(column_type: type, | ||
| filter_operators: ApiFilters.forest_operators(name), | ||
| is_read_only: true)) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| module ForestAdminDatasourcePylon | ||
| module Collections | ||
| class Account < CursorCollection | ||
| module Serializer | ||
| NATIVE_FIELDS = %w[id name type is_disabled domain primary_domain domains tags external_ids | ||
| channels crm_settings created_at updated_at | ||
| latest_customer_activity_time].freeze | ||
|
|
||
| private | ||
|
|
||
| def serialize(account) | ||
| attrs = account.is_a?(Hash) ? account : {} | ||
| record = NATIVE_FIELDS.to_h { |field| [field, attrs[field]] } | ||
| record['owner_id'] = nested_id(attrs['owner']) | ||
| add_custom_field_values(record, attrs['custom_fields']) | ||
| record | ||
| 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 = 4): search_accounts [qlty:function-parameters]