Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ class ConfigurationError < Error; end
# they learn which one.
class UnsupportedOperatorError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end

# The three write errors below descend from ValidationError for that same
# reason: each names something the operator did and can undo.

# A verb Pylon's API has no endpoint for, a field it only accepts in the other
# direction, or a write reaching more records than one pass may cover.
class UnsupportedWriteError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end

# A write Pylon performed on some of its records and then failed on: one
# record is one request, so the ones before the failure stay written, and a
# retry of the whole selection would write them a second time.
class PartialWriteError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end

# A write Pylon itself refused, carrying the reason it gave — the likeliest
# way a write fails. Only its 4xx travels this way: a 5xx or a dropped
# connection is not the operator's to act on and stays the APIError it was.
class WriteRejectedError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end

# Raised when a Pylon API call fails. Carries the HTTP status and the
# (parsed) response body so callers — smart actions in particular — can
# surface Pylon's own validation message instead of a generic string.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module ForestAdminDatasourcePylon
# 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
include Writes

MAX_SEARCH_LIMIT = 1000

# Bounds `collect_pages`, which asks for a whole dataset rather than a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
module ForestAdminDatasourcePylon
class Client
# The write half of the client: one explicit method per Pylon write
# endpoint, each delegating to the shared helpers below.
#
# Nothing here degrades. `best_effort` exists for the calls whose result
# enriches a page — a thread that could not be read costs a column — where a
# write that silently did nothing would tell the operator their edit landed.
#
# Pylon exposes no POST or DELETE on users, and no DELETE on teams. The
# collections answer those, not the client, which only spells the endpoints
# that exist.
module Writes
# `title` and `body_html` are the two fields POST /issues requires.
def create_issue(attributes) = post_resource('issues', attributes)
def update_issue(id, attributes) = patch_resource('issues', id, attributes)
def delete_issue(id) = delete_resource('issues', id)

def create_account(attributes) = post_resource('accounts', attributes)
def update_account(id, attributes) = patch_resource('accounts', id, attributes)
def delete_account(id) = delete_resource('accounts', id)

def create_contact(attributes) = post_resource('contacts', attributes)
def update_contact(id, attributes) = patch_resource('contacts', id, attributes)
def delete_contact(id) = delete_resource('contacts', id)

def create_team(attributes) = post_resource('teams', attributes)
def update_team(id, attributes) = patch_resource('teams', id, attributes)

def update_user(id, attributes) = patch_resource('users', id, attributes)

private

def post_resource(resource, attributes)
operation = "create(#{resource})"

must_succeed(operation) { extract_written(connection.post(resource, attributes).body, operation) }
end

# The id comes from the record the operator acted on, so it is escaped
# before being joined to the path, like every read does.
def patch_resource(resource, id, attributes)
path = "#{resource}/#{Faraday::Utils.escape(id)}"
operation = "update(#{path})"

must_succeed(operation) { extract_updated(connection.patch(path, attributes).body, operation) }
end

# Answers true rather than the body: Pylon returns 200 or 204 with nothing
# worth reading, and a caller has no record left to serialize.
def delete_resource(resource, id)
path = "#{resource}/#{Faraday::Utils.escape(id)}"

must_succeed("delete(#{path})") do
connection.delete(path)
true
end
end

# Pylon answers a write with the written record under `data`. Anything else
# broke the contract: `extract_data` hands the body back untouched when
# `data` is absent, which is what a read wants and a write must not accept
# — the collection would serialize the envelope into a record with no id.
def extract_written(body, operation)
record = body['data'] if body.is_a?(Hash)
return record if record.is_a?(Hash)

refuse_body_shape(body, operation, "missing 'data'")
end

# An update discards its record, so a 204, an empty body or a null `data`
# is the write having landed with nothing to hand back: raising there would
# report a failure on a record Pylon already patched, and abort the records
# a bulk edit had left to write.
def extract_updated(body, operation)
record = body['data'] if body.is_a?(Hash)
return record if record.nil? || record.is_a?(Hash)

refuse_body_shape(body, operation, "'data' is not a record")
end
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

def refuse_body_shape(body, operation, detail)
raise APIError,
"Pylon API #{operation} returned an unexpected body shape (#{detail}): #{body.inspect}"
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ class Account < CursorCollection
include SchemaDefinition
include Serializer

# Pylon reads an account's type back as `type` and takes it as
# `account_type`.
RENAMES = { 'type' => 'account_type' }.freeze

# An account is created enabled; only `PATCH /accounts/{id}` disables one.
UPDATE_ONLY = %w[is_disabled].freeze

def initialize(datasource, custom_fields: [])
super(datasource, 'PylonAccount', custom_fields: custom_fields, searchable: true)
end
Expand All @@ -12,6 +19,13 @@ def initialize(datasource, custom_fields: [])

def filter_table = ApiFilters

def create_record(payload) = datasource.client.create_account(payload)
def update_record(id, payload) = datasource.client.update_account(id, payload)
def delete_record(id) = datasource.client.delete_account(id)

def update_only_fields = UPDATE_ONLY
def payload_renames = RENAMES

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.'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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.
# A column is writable when `POST /accounts` or `PATCH /accounts/{id}`
# accepts it, in the shape it is read under — the Json columns holding
# objects rather than plain strings are left read-only, see below. No
# column is sortable — 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
Expand Down Expand Up @@ -41,29 +43,40 @@ def define_relations

def define_identity_fields
add_column('id', 'String', is_primary_key: true)
add_column('name', 'String')
add_column('name', 'String', writable: true)
# 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')
# prospect but lets an organization define its own account types. It
# is written under the name `account_type`, see `Account::RENAMES`.
add_column('type', 'String', writable: true)
# Writable on an update only: an account is created enabled.
add_column('is_disabled', 'Boolean', writable: true)
end

# `domain` and `primary_domain` carry the same value; both are kept
# because Pylon returns both, and only the `domains` list is filterable.
# Neither is writable: `domains` is the list the API takes, and writing
# one of its two projections would leave the other stale.
def define_domain_fields
add_column('domain', 'String')
add_column('primary_domain', 'String')
add_column('domains', 'Json')
add_column('tags', 'Json')
add_column('domains', 'Json', writable: true)
add_column('tags', 'Json', writable: true)
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('owner_id', 'String', writable: true)
# Read-only although the endpoint takes it: the column shows
# `{external_id, label}` objects, and the write shape the reference
# documents is not that one — writing one for the other would replace
# the ids of the account with something it cannot read.
add_column('external_ids', 'Json')
end

# Both belong to the integrations Pylon syncs them from: `crm_settings`
# is absent from every write endpoint, and `channels` — which they do
# take — holds objects, like `external_ids` above.
def define_integration_fields
add_column('channels', 'Json')
add_column('crm_settings', 'Json')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module ForestAdminDatasourcePylon
module Collections
class BaseCollection < ForestAdminDatasourceToolkit::Collection
include Writes

ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema
ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
Expand Down Expand Up @@ -198,18 +200,18 @@ def api_filters
end
end

# A native column: read-only in this story — writes land in a later one —
# A native column: read-only unless the collection declares it `writable`,
# and never groupable, as no Pylon endpoint aggregates. It is not sortable
# either, the ColumnSchema default, because no search endpoint takes a sort
# parameter. Filter operators are not chosen here: they come from
# `filter_table`, which mirrors the allow-list of the API, so a column
# missing from it gets none and the UI offers no filter Pylon would refuse.
def add_column(name, type, is_primary_key: false)
def add_column(name, type, is_primary_key: false, writable: 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): add_column [qlty:function-parameters]

add_field(name, ColumnSchema.new(column_type: type,
filter_operators: filter_table.forest_operators(name),
is_primary_key: is_primary_key,
is_groupable: false,
is_read_only: true))
is_read_only: !writable))
end

# A record read through the endpoint of an id that is not the primary key
Expand Down Expand Up @@ -262,6 +264,11 @@ def default_pk_sort?(sort)
normalized_sort_clauses(sort) == normalized_sort_clauses(SortFactory.by_primary_keys(self))
end

# The search box sends an empty string once the operator clears it.
def no_search?(filter)
filter&.search.to_s.strip.empty?
end

def timezone_for(caller)
return 'UTC' unless caller.respond_to?(:timezone)

Expand Down Expand Up @@ -332,11 +339,15 @@ def walker
@walker ||= Pagination::CursorWalker.new
end

# A set of ids, not a list: the same one named twice is one record, so a
# lookup spends one request on it and a delete does not answer 404 the
# second time. The caps count records rather than mentions for the same
# reason.
def id_values(node)
return nil unless node.is_a?(Leaf) && node.field == 'id'
return nil unless [Operators::EQUAL, Operators::IN].include?(node.operator)

Array(node.value).map(&:to_s).reject(&:empty?)
Array(node.value).map(&:to_s).reject(&:empty?).uniq
end

def and_branch?(node)
Expand All @@ -346,9 +357,11 @@ def and_branch?(node)
# An `id` the short-circuit could not take out of the tree has no
# translation left: the endpoint filters no id server-side, and an id under
# an OR cannot be narrowed to a lookup because the other side of the union
# would bring in records the lookup never fetched. The UI does offer both
# an `id equals` filter and the or/and toggle, so this is worth an error an
# operator can act on rather than the translator's "add it to api_filters".
# would bring in records the lookup never fetched. Worth an error an
# operator can act on rather than the translator's "add it to api_filters",
# because two things they do reach it: the `id equals` filter next to the
# or/and toggle, and an excluding selection — "every record except these" —
# which arrives as `id not_in` and is no filter they wrote.
#
# A collection whose endpoint does filter id declares it in `api_filters`
# and never short-circuits, so the translator handles its ids like any
Expand All @@ -358,9 +371,11 @@ def ensure_no_stray_id!(node)
return unless node.some_leaf { |leaf| leaf.field == 'id' }

raise UnsupportedOperatorError,
"A filter on 'id' has to be combined with 'and' conditions only: Pylon cannot filter on id, so the " \
'agent reads the records by id and applies the rest in memory, which an id inside an `or` would ' \
'silently widen. Rewrite the filter with `and`, or filter on another field.'
"#{name} cannot answer this selection: Pylon cannot filter on id, so the agent reads the records " \
'by id and applies the rest in memory, which only an `and` of `id equals` / `id in` conditions ' \
'names a set of records to read. An id inside an `or` names none, and neither does an exclusion, ' \
'which is what selecting every record except a few sends. Select the records to act on rather ' \
'than the ones to leave out, rewrite the filter with `and`, or filter on another field.'
end

def resolve_relation_conditions(caller, node)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ class Contact < CursorCollection
include SchemaDefinition
include Serializer

# `POST /contacts` takes the primary address alone; the other ones are set
# on an existing contact, through the list.
CREATE_ONLY = %w[email].freeze
UPDATE_ONLY = %w[emails].freeze

def initialize(datasource, custom_fields: [])
super(datasource, 'PylonContact', custom_fields: custom_fields, searchable: true)
end
Expand All @@ -12,6 +17,13 @@ def initialize(datasource, custom_fields: [])

def filter_table = ApiFilters

def create_record(payload) = datasource.client.create_contact(payload)
def update_record(id, payload) = datasource.client.update_contact(id, payload)
def delete_record(id) = datasource.client.delete_contact(id)

def create_only_fields = CREATE_ONLY
def update_only_fields = UPDATE_ONLY

def unsortable_warning
'[forest_admin_datasource_pylon] PylonContact cannot honour the requested order; neither GET /contacts ' \
'nor POST /contacts/search takes a sort parameter, so contacts come back in the order the API imposes.'
Expand Down
Loading
Loading