forest_admin_datasource_pylon 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/README.md +179 -0
- data/Rakefile +6 -0
- data/forest_admin_datasource_pylon.gemspec +36 -0
- data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
- data/lib/forest_admin_datasource_pylon/client.rb +436 -0
- data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
- data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
- data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
- data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
- data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
- data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
- data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
- data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
- data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
- data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
- data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
- data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
- data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
- data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
- data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
- data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
- data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
- data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
- data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
- data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
- data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
- data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
- data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
- data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
- data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
- data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
- data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
- data/lib/forest_admin_datasource_pylon/version.rb +3 -0
- data/lib/forest_admin_datasource_pylon.rb +70 -0
- metadata +152 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
# Long by line count only: the public surface is one explicit method per Pylon
|
|
3
|
+
# endpoint, each delegating to the shared helpers below.
|
|
4
|
+
class Client # rubocop:disable Metrics/ClassLength
|
|
5
|
+
include Writes
|
|
6
|
+
|
|
7
|
+
MAX_SEARCH_LIMIT = 1000
|
|
8
|
+
|
|
9
|
+
# Bounds `collect_pages`, which asks for a whole dataset rather than a
|
|
10
|
+
# window: the endpoints it reads answer in one response, so reaching this
|
|
11
|
+
# many pages means the API started paginating on its own and the walk is
|
|
12
|
+
# spending more of the per-minute budget than the answer is worth.
|
|
13
|
+
#
|
|
14
|
+
# What reaching it costs is the caller's to say: a conversation thread and a
|
|
15
|
+
# custom-field list are truncated with a warning, where a collection whose
|
|
16
|
+
# answer is only correct whole is refused. See `refuse_past_cap`.
|
|
17
|
+
MAX_COLLECTED_PAGES = 10
|
|
18
|
+
|
|
19
|
+
# `next_cursor` is nil as soon as Pylon stops advertising a next page, so
|
|
20
|
+
# callers never have to know how the absence is spelled on the wire.
|
|
21
|
+
SearchPage = Struct.new(:records, :next_cursor, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
def initialize(configuration)
|
|
24
|
+
@configuration = configuration
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Health check: Pylon returns the details of the organization owning the
|
|
28
|
+
# token, which is enough to prove the credentials are usable.
|
|
29
|
+
def me
|
|
30
|
+
must_succeed('me') { extract_data(connection.get('me').body) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# POST /issues/search accepts an empty body and then returns the most recent
|
|
34
|
+
# issues, ordered by `created_at` descending.
|
|
35
|
+
def search_issues(limit:, cursor: nil, filter: nil, search_text: nil)
|
|
36
|
+
search_resource('issues/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Accepts either the UUID or the issue number.
|
|
40
|
+
def fetch_issue(id)
|
|
41
|
+
fetch_resource('issues', id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The whole conversation of an issue, oldest message first, or nil when the
|
|
45
|
+
# thread could not be read.
|
|
46
|
+
#
|
|
47
|
+
# `limit` is left out on purpose: Pylon then answers with every message in a
|
|
48
|
+
# single response. Asking for a page would hand back the OLDEST messages and
|
|
49
|
+
# cut the most recent ones off, which is the half of a conversation nobody
|
|
50
|
+
# opens a ticket to read.
|
|
51
|
+
#
|
|
52
|
+
# The cursor is still followed, defensively: Pylon paginates this endpoint
|
|
53
|
+
# when asked to, so a future default page size stays handled rather than
|
|
54
|
+
# silently truncating the thread.
|
|
55
|
+
def fetch_issue_messages(issue_id)
|
|
56
|
+
path = "issues/#{Faraday::Utils.escape(issue_id)}/messages"
|
|
57
|
+
|
|
58
|
+
best_effort("fetch_issue_messages(#{issue_id})", default: nil) { must_succeed(path) { collect_pages(path) } }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def search_accounts(limit:, cursor: nil, filter: nil, search_text: nil)
|
|
62
|
+
search_resource('accounts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def list_accounts(limit:, cursor: nil)
|
|
66
|
+
list_resource('accounts', limit: limit, cursor: cursor)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Accepts either the Pylon UUID or the account's external id.
|
|
70
|
+
def fetch_account(id)
|
|
71
|
+
fetch_resource('accounts', id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def search_contacts(limit:, cursor: nil, filter: nil, search_text: nil)
|
|
75
|
+
search_resource('contacts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# GET /contacts is paginated exactly like GET /accounts even though the
|
|
79
|
+
# OpenAPI spec forgets to document its query parameters.
|
|
80
|
+
def list_contacts(limit:, cursor: nil)
|
|
81
|
+
list_resource('contacts', limit: limit, cursor: cursor)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fetch_contact(id)
|
|
85
|
+
fetch_resource('contacts', id)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# GET /users is unpaginated. Deactivated agents are included by default so
|
|
89
|
+
# that assignees of older issues stay resolvable.
|
|
90
|
+
def fetch_users(include_deactivated: true)
|
|
91
|
+
fetch_all('users', 'include_deactivated' => include_deactivated)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def fetch_user(id)
|
|
95
|
+
fetch_resource('users', id)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# GET /teams is unpaginated and takes no parameter.
|
|
99
|
+
def fetch_teams
|
|
100
|
+
fetch_all('teams')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fetch_team(id)
|
|
104
|
+
fetch_resource('teams', id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The custom-field definitions of one object type. `object_type` is
|
|
108
|
+
# mandatory on this endpoint, so a schema spanning several collections costs
|
|
109
|
+
# one call per collection rather than one call in total.
|
|
110
|
+
#
|
|
111
|
+
# Degrades to nil: this is read while the agent boots, and a token missing
|
|
112
|
+
# the permission — or a Pylon that happens to be down right then — has to
|
|
113
|
+
# cost the operator the custom columns, not the whole datasource.
|
|
114
|
+
#
|
|
115
|
+
# nil rather than an empty list so the caller can tell a failure from an
|
|
116
|
+
# organization that defined no custom field, the two costing very different
|
|
117
|
+
# things: the second says nothing about the next object type, the first says
|
|
118
|
+
# it will almost certainly fail the same way.
|
|
119
|
+
#
|
|
120
|
+
# Which is why it goes through `boot_connection`: a call declared best-effort
|
|
121
|
+
# has no business holding the boot for the minutes the resilient policy is
|
|
122
|
+
# willing to spend waiting a 429 out. The bound is per request, and the walk
|
|
123
|
+
# is allowed the same pages as any other — one is what this endpoint answers
|
|
124
|
+
# with, having a handful of definitions to return per object type.
|
|
125
|
+
def fetch_custom_fields(object_type)
|
|
126
|
+
params = { 'object_type' => object_type }
|
|
127
|
+
|
|
128
|
+
best_effort("fetch_custom_fields(#{object_type})", default: nil) do
|
|
129
|
+
must_succeed('custom-fields') { collect_pages('custom-fields', params, conn: boot_connection) }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The two memoized connections carry the bearer token in their headers, and
|
|
134
|
+
# `Faraday::Connection#inspect` prints those in clear: a Client reaching an
|
|
135
|
+
# `inspect` by accident leaks the credential whatever `Configuration` does
|
|
136
|
+
# about its own. Masked here for that reason, and not only for symmetry.
|
|
137
|
+
#
|
|
138
|
+
# The url goes through `redacted_url`, not `url`: a base url fronted by an
|
|
139
|
+
# egress proxy carries that proxy's credentials in its user-info, and this
|
|
140
|
+
# is one of the two places that print it.
|
|
141
|
+
def inspect
|
|
142
|
+
"#<#{self.class.name} base_url=#{@configuration.redacted_url.inspect}>"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def search_resource(path, limit:, cursor: nil, filter: nil, search_text: nil)
|
|
148
|
+
body = { 'limit' => clamp_limit(limit) }
|
|
149
|
+
body['cursor'] = cursor unless blank?(cursor)
|
|
150
|
+
body['filter'] = filter unless filter.nil?
|
|
151
|
+
body['search_text'] = search_text unless blank?(search_text)
|
|
152
|
+
|
|
153
|
+
must_succeed(path) { to_search_page(connection.post(path, body).body, path) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# `limit` is mandatory on the paginated GET endpoints, unlike their POST
|
|
157
|
+
# /search counterparts which default it server-side.
|
|
158
|
+
def list_resource(path, limit:, cursor: nil)
|
|
159
|
+
params = { 'limit' => clamp_limit(limit) }
|
|
160
|
+
params['cursor'] = cursor unless blank?(cursor)
|
|
161
|
+
|
|
162
|
+
must_succeed(path) { to_search_page(connection.get(path, params).body, path) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The endpoints documented as answering with the whole collection in one
|
|
166
|
+
# response, `GET /users` and `GET /teams`. The cursor is still followed, the
|
|
167
|
+
# way `fetch_issue_messages` follows it: Pylon advertises no next page here,
|
|
168
|
+
# so the walk costs the single request it always did and only does something
|
|
169
|
+
# the day that changes.
|
|
170
|
+
#
|
|
171
|
+
# Past the cap the walk is refused rather than truncated, unlike the thread
|
|
172
|
+
# and the custom fields: `FetchAllCollection` filters, sorts, counts and
|
|
173
|
+
# groups in memory over what this returns, and calls the result exact
|
|
174
|
+
# BECAUSE it is every record Pylon holds. Truncated, that claim would stand
|
|
175
|
+
# over a fraction of the collection -- a count answered short and presented
|
|
176
|
+
# as exact, which is the one answer this datasource refuses everywhere else.
|
|
177
|
+
def fetch_all(path, params = {})
|
|
178
|
+
must_succeed(path) { collect_pages(path, params, refuse_past_cap: true) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Every record of a cursor-paginated GET, no window asked for and no limit
|
|
182
|
+
# sent. `CursorWalker` answers the other question — the offset/limit window a
|
|
183
|
+
# list view asks for — and is not what this needs.
|
|
184
|
+
#
|
|
185
|
+
# `params` ride along on every page, cursor included: a mandatory parameter
|
|
186
|
+
# dropped on the second request answers a different question than the first.
|
|
187
|
+
#
|
|
188
|
+
# An empty page, a cursor that does not move, a cursor already followed and
|
|
189
|
+
# the page cap all stop the loop: none happens today, but a walk driven by a
|
|
190
|
+
# remote value stops on its own terms rather than collecting the same page
|
|
191
|
+
# twice over.
|
|
192
|
+
#
|
|
193
|
+
# Only `next_cursor` being nil is Pylon saying there is nothing more. Every
|
|
194
|
+
# other stop leaves records behind, and `refuse_past_cap` is which of the two
|
|
195
|
+
# answers the caller wants to those: a warning and a short list, or a refusal.
|
|
196
|
+
# See `fetch_all`, whose answer is only correct whole.
|
|
197
|
+
#
|
|
198
|
+
# `conn` is what a caller reading on the boot path hands its own connection
|
|
199
|
+
# through: every page of the walk is then bounded like the first.
|
|
200
|
+
def collect_pages(path, params = {}, conn: connection, refuse_past_cap: false)
|
|
201
|
+
records = []
|
|
202
|
+
cursor = nil
|
|
203
|
+
seen = Set.new
|
|
204
|
+
pages = 0
|
|
205
|
+
|
|
206
|
+
loop do
|
|
207
|
+
query = cursor.nil? ? params : params.merge('cursor' => cursor)
|
|
208
|
+
page = to_search_page(conn.get(path, query).body, path)
|
|
209
|
+
records.concat(page.records)
|
|
210
|
+
pages += 1
|
|
211
|
+
break if page.next_cursor.nil?
|
|
212
|
+
|
|
213
|
+
reason = truncation_reason(page, pages, seen)
|
|
214
|
+
if reason
|
|
215
|
+
refuse_truncated_walk(path, reason, pages, records.size) if refuse_past_cap
|
|
216
|
+
|
|
217
|
+
log_truncated_walk(path, reason, pages, records.size)
|
|
218
|
+
break
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
cursor = page.next_cursor
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
records
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Why a walk stopped short of the end Pylon would have declared, or nil when
|
|
228
|
+
# it may go on. `seen` is fed here, once per page and only for a cursor that
|
|
229
|
+
# could still be followed.
|
|
230
|
+
def truncation_reason(page, pages, seen)
|
|
231
|
+
return 'it answered an empty page while advertising another' if page.records.empty?
|
|
232
|
+
return 'it answered a cursor it had already answered' unless seen.add?(page.next_cursor)
|
|
233
|
+
return "it paginated past the #{MAX_COLLECTED_PAGES} pages one walk covers" if pages >= MAX_COLLECTED_PAGES
|
|
234
|
+
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Refused as an APIError, like a body whose shape broke the contract: what
|
|
239
|
+
# happened is that Pylon started answering an endpoint differently, which no
|
|
240
|
+
# filter the operator sets and no page they ask for would work around.
|
|
241
|
+
def refuse_truncated_walk(path, reason, pages, collected)
|
|
242
|
+
raise APIError,
|
|
243
|
+
"Pylon left the walk of #{path} short after #{pages} page(s) / #{collected} record(s): #{reason}. " \
|
|
244
|
+
'That endpoint is documented as answering with the whole collection in one response, which is what ' \
|
|
245
|
+
'PylonUser and PylonTeam filter, sort and count in memory as an exact answer. Refusing rather ' \
|
|
246
|
+
'than answering over a fraction of the collection: reading it needs cursor pagination, the way ' \
|
|
247
|
+
'PylonIssue reads its own.'
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def log_truncated_walk(path, reason, pages, collected)
|
|
251
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
252
|
+
"[forest_admin_datasource_pylon] Stopped paginating #{path} after #{pages} page(s) / " \
|
|
253
|
+
"#{collected} record(s): #{reason}; the rest is left out."
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The id comes from operator-supplied filter values, so it is escaped before
|
|
258
|
+
# being joined to the path.
|
|
259
|
+
def fetch_resource(resource, id)
|
|
260
|
+
path = "#{resource}/#{Faraday::Utils.escape(id)}"
|
|
261
|
+
must_succeed(path) { extract_record(connection.get(path).body, path) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def clamp_limit(limit)
|
|
265
|
+
value = limit.to_i
|
|
266
|
+
return 1 if value < 1
|
|
267
|
+
|
|
268
|
+
[value, MAX_SEARCH_LIMIT].min
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Pylon only includes the `pagination` block when a next page exists, so an
|
|
272
|
+
# absent block, `has_next_page: false` and an empty cursor all mean "done".
|
|
273
|
+
def to_search_page(body, operation)
|
|
274
|
+
pagination = body.is_a?(Hash) ? body['pagination'] : nil
|
|
275
|
+
cursor = pagination.is_a?(Hash) && pagination['has_next_page'] ? pagination['cursor'] : nil
|
|
276
|
+
|
|
277
|
+
SearchPage.new(records: extract_list(body, operation), next_cursor: blank?(cursor) ? nil : cursor)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# A read expects `data` to hold what it asked for, and anything else broke
|
|
281
|
+
# the contract. `extract_data` hands an envelope carrying no `data` straight
|
|
282
|
+
# back, which `Array()` would then split into `[key, value]` pairs and the
|
|
283
|
+
# collection would serialize into rows holding nothing -- a page that looks
|
|
284
|
+
# answered and is empty. Refused instead, the way the write path already
|
|
285
|
+
# refuses the same shape: see `Client::Writes#extract_written`.
|
|
286
|
+
#
|
|
287
|
+
# An absent or null `data` stays the empty answer it is: Pylon spells "no
|
|
288
|
+
# record" that way, and a search matching nothing is not a broken contract.
|
|
289
|
+
def extract_list(body, operation)
|
|
290
|
+
data = extract_data(body)
|
|
291
|
+
return [] if data.nil?
|
|
292
|
+
return data if data.is_a?(Array)
|
|
293
|
+
|
|
294
|
+
refuse_body_shape(body, operation, "'data' is not a list")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# The single-record half of the same check. nil travels: it is what a
|
|
298
|
+
# caller reads as "no such record".
|
|
299
|
+
def extract_record(body, operation)
|
|
300
|
+
data = extract_data(body)
|
|
301
|
+
return data if data.nil? || data.is_a?(Hash)
|
|
302
|
+
|
|
303
|
+
refuse_body_shape(body, operation, "'data' is not a record")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def blank?(value)
|
|
307
|
+
value.nil? || value.to_s.empty?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Pylon wraps payloads in { "data": ..., "pagination": ..., "request_id": ... }.
|
|
311
|
+
def extract_data(body)
|
|
312
|
+
return nil if body.nil? || body == ''
|
|
313
|
+
return body['data'] if body.is_a?(Hash) && body.key?('data')
|
|
314
|
+
|
|
315
|
+
body
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def must_succeed(operation)
|
|
319
|
+
yield
|
|
320
|
+
rescue Faraday::Error => e
|
|
321
|
+
raise api_error(operation, e)
|
|
322
|
+
rescue APIError
|
|
323
|
+
# Already mapped, with its status intact; re-wrapping would erase it.
|
|
324
|
+
raise
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
raise APIError, "Pylon API call failed: #{operation}: #{e.class}: #{e.message}"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# For the calls whose result enriches a page rather than being the page: the
|
|
330
|
+
# failure is reported and the default returned, so a degraded thread or a
|
|
331
|
+
# missing enrichment costs the operator a column, not the record they opened.
|
|
332
|
+
def best_effort(operation, default:)
|
|
333
|
+
yield
|
|
334
|
+
rescue StandardError => e
|
|
335
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
336
|
+
"[forest_admin_datasource_pylon] #{operation} failed; degrading: #{e.class}: #{e.message}"
|
|
337
|
+
)
|
|
338
|
+
default
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Builds an APIError preserving the HTTP status and Pylon's own error body so
|
|
342
|
+
# smart actions can show the operator the real reason instead of "failed".
|
|
343
|
+
def api_error(operation, error)
|
|
344
|
+
response = error.respond_to?(:response) ? error.response : nil
|
|
345
|
+
status = response.is_a?(Hash) ? response[:status] : nil
|
|
346
|
+
body = parse_body(response.is_a?(Hash) ? response[:body] : nil)
|
|
347
|
+
detail = error_detail(status, body) || "#{error.class}: #{error.message}"
|
|
348
|
+
APIError.new("Pylon API call failed: #{operation}: #{detail}", status: status, body: body)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def error_detail(status, body)
|
|
352
|
+
return nil unless status
|
|
353
|
+
|
|
354
|
+
"HTTP #{status} #{error_message(body)}".strip
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def error_message(parsed)
|
|
358
|
+
return parsed.to_s[0, 500] unless parsed.is_a?(Hash)
|
|
359
|
+
|
|
360
|
+
nested = parsed['error']
|
|
361
|
+
message = parsed['message'] || (nested.is_a?(Hash) ? nested['message'] : nested) ||
|
|
362
|
+
join_errors(parsed['errors'])
|
|
363
|
+
message = parsed.to_json if message.to_s.empty?
|
|
364
|
+
# Truncate before appending: the request_id is what support needs, so it
|
|
365
|
+
# must not be the first thing a long error body pushes out.
|
|
366
|
+
append_request_id(message.to_s[0, 500], parsed['request_id'])
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def append_request_id(message, request_id)
|
|
370
|
+
return message unless request_id
|
|
371
|
+
|
|
372
|
+
"#{message} (request_id: #{request_id})"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def join_errors(errors)
|
|
376
|
+
Array(errors).filter_map { |e| e.is_a?(Hash) ? (e['message'] || e['detail']) : e }.join('; ')
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def parse_body(body)
|
|
380
|
+
return body unless body.is_a?(String) && !body.empty?
|
|
381
|
+
|
|
382
|
+
JSON.parse(body)
|
|
383
|
+
rescue JSON::ParserError
|
|
384
|
+
body
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def connection
|
|
388
|
+
@connection ||= build_connection(
|
|
389
|
+
retry_policy: @configuration.retry_policy,
|
|
390
|
+
timeout: @configuration.timeout,
|
|
391
|
+
open_timeout: @configuration.open_timeout
|
|
392
|
+
)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# For what is read while the datasource is being constructed: short timeouts
|
|
396
|
+
# and one quick retry, so the introspection cannot turn a Pylon that is down
|
|
397
|
+
# into minutes of Rails boot. Memoized separately from `connection`, which
|
|
398
|
+
# keeps the patience every later request is entitled to.
|
|
399
|
+
def boot_connection
|
|
400
|
+
@boot_connection ||= build_connection(
|
|
401
|
+
retry_policy: @configuration.boot_retry_policy,
|
|
402
|
+
timeout: @configuration.boot_timeout,
|
|
403
|
+
open_timeout: @configuration.boot_open_timeout
|
|
404
|
+
)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Middleware order is deliberate: `raise_error` sits outside the JSON parser
|
|
408
|
+
# so it raises with an already-parsed body, and `retry` sits innermost so it
|
|
409
|
+
# inspects raw statuses — behind `raise_error` it would never see a 429.
|
|
410
|
+
#
|
|
411
|
+
# The throttle goes inside `retry`, which is what makes a replay wait for a
|
|
412
|
+
# slot like a first attempt: outside it, the middleware would run once for a
|
|
413
|
+
# request that reached Pylon three times.
|
|
414
|
+
#
|
|
415
|
+
# How long a request is allowed to take is the caller's to state, everything
|
|
416
|
+
# else being the same on every connection this builds: the limiter included,
|
|
417
|
+
# a second connection metering in a window of its own spending the budget of
|
|
418
|
+
# the endpoint twice over.
|
|
419
|
+
def build_connection(retry_policy:, timeout:, open_timeout:)
|
|
420
|
+
Faraday.new(url: @configuration.url) do |f|
|
|
421
|
+
f.request :json
|
|
422
|
+
f.response :raise_error
|
|
423
|
+
f.response :json
|
|
424
|
+
f.request :retry, **retry_policy.to_faraday_options
|
|
425
|
+
if @configuration.rate_limiter
|
|
426
|
+
f.use Throttle, limiter: @configuration.rate_limiter, base_path: @configuration.base_path
|
|
427
|
+
end
|
|
428
|
+
f.headers['Authorization'] = "Bearer #{@configuration.api_key}"
|
|
429
|
+
f.headers['Accept'] = 'application/json'
|
|
430
|
+
f.headers['User-Agent'] = "forest_admin_datasource_pylon/#{VERSION}"
|
|
431
|
+
f.options.open_timeout = open_timeout
|
|
432
|
+
f.options.timeout = timeout
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Account < CursorCollection
|
|
4
|
+
# The allow-list of `POST /accounts/search`, transcribed from the API
|
|
5
|
+
# reference: a field absent from this table cannot be filtered at all, and
|
|
6
|
+
# an operator absent from a field's map is rejected by Pylon.
|
|
7
|
+
#
|
|
8
|
+
# It is the single source of truth for filtering — `define_schema` derives
|
|
9
|
+
# every column's `filter_operators` from it, so this collection declares no
|
|
10
|
+
# filter the translator would then refuse; the absence family the agent
|
|
11
|
+
# derives on top of it is the exception `Query::OperatorMaps::Table`
|
|
12
|
+
# describes.
|
|
13
|
+
module ApiFilters
|
|
14
|
+
Maps = Query::OperatorMaps
|
|
15
|
+
|
|
16
|
+
extend Maps::Table
|
|
17
|
+
|
|
18
|
+
CUSTOM_FIELD_OPS = Maps::CUSTOM_FIELD_OPS
|
|
19
|
+
|
|
20
|
+
# `id` is filtered server-side here, which is what spares this
|
|
21
|
+
# collection the primary-key short-circuit Issue needs.
|
|
22
|
+
#
|
|
23
|
+
# `name` gets SUBSTRING rather than FULL_TEXT: the endpoint accepts
|
|
24
|
+
# `string_contains` but no negation of it. `external_ids` is left out
|
|
25
|
+
# entirely although the endpoint filters it — the API matches the bare
|
|
26
|
+
# external-id strings while the column shows `{external_id, label}`
|
|
27
|
+
# objects, so the filter would run on something the operator cannot see.
|
|
28
|
+
# The account read endpoint accepts an external id in place of the
|
|
29
|
+
# primary key, which is the way to reach a record by one.
|
|
30
|
+
#
|
|
31
|
+
# No time field is filterable: `created_at`, `updated_at` and
|
|
32
|
+
# `latest_customer_activity_time` are absent from the allow-list.
|
|
33
|
+
API_FILTERS = {
|
|
34
|
+
'id' => { ops: Maps::EQUALITY },
|
|
35
|
+
'name' => { ops: Maps::EQUALITY.merge(Maps::SUBSTRING) },
|
|
36
|
+
'domains' => { ops: Maps::MEMBERSHIP },
|
|
37
|
+
'tags' => { ops: Maps::MEMBERSHIP },
|
|
38
|
+
'owner_id' => { ops: Maps::EQUALITY.merge(Maps::PRESENCE) }
|
|
39
|
+
}.freeze
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Account < CursorCollection
|
|
4
|
+
# A column is writable when `POST /accounts` or `PATCH /accounts/{id}`
|
|
5
|
+
# accepts it, in the shape it is read under — the Json columns holding
|
|
6
|
+
# objects rather than plain strings are left read-only, see below. No
|
|
7
|
+
# column is sortable — neither `GET /accounts` nor `POST /accounts/search`
|
|
8
|
+
# exposes a sort parameter, so advertising a sortable column would let the
|
|
9
|
+
# UI ask for an order the API cannot honour.
|
|
10
|
+
#
|
|
11
|
+
# Filter operators are not chosen here: they come from
|
|
12
|
+
# `ApiFilters::API_FILTERS`, which mirrors the allow-list of the API. A
|
|
13
|
+
# column missing from that table gets no operator, so the UI offers no
|
|
14
|
+
# filter of this collection's own that Pylon would refuse — the absence
|
|
15
|
+
# family the agent derives above the datasource being the exception
|
|
16
|
+
# `Query::OperatorMaps::Table` describes.
|
|
17
|
+
module SchemaDefinition
|
|
18
|
+
OneToManySchema = BaseCollection::OneToManySchema
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def define_schema
|
|
23
|
+
define_identity_fields
|
|
24
|
+
define_domain_fields
|
|
25
|
+
define_ownership_fields
|
|
26
|
+
define_integration_fields
|
|
27
|
+
define_time_fields
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The reverse sides of the two ManyToOne relations pointing here. Both
|
|
31
|
+
# `/issues/search` and `/contacts/search` filter `account_id`
|
|
32
|
+
# server-side, so a related list is one request and no in-memory pass.
|
|
33
|
+
#
|
|
34
|
+
# `owner_id` stays a plain column: it does point at a PylonUser, and the
|
|
35
|
+
# embedder would resolve it like any other key, but nothing in the panel
|
|
36
|
+
# asks for the owner of an account yet.
|
|
37
|
+
def define_relations
|
|
38
|
+
add_field('issues', OneToManySchema.new(foreign_collection: 'PylonIssue',
|
|
39
|
+
origin_key: 'account_id', origin_key_target: 'id'))
|
|
40
|
+
add_field('contacts', OneToManySchema.new(foreign_collection: 'PylonContact',
|
|
41
|
+
origin_key: 'account_id', origin_key_target: 'id'))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def define_identity_fields
|
|
45
|
+
add_column('id', 'String', is_primary_key: true)
|
|
46
|
+
add_column('name', 'String', writable: true)
|
|
47
|
+
# Left as String rather than Enum: Pylon ships customer / partner /
|
|
48
|
+
# prospect but lets an organization define its own account types. It
|
|
49
|
+
# is written under the name `account_type`, see `Account::RENAMES`.
|
|
50
|
+
add_column('type', 'String', writable: true)
|
|
51
|
+
# Writable on an update only: an account is created enabled.
|
|
52
|
+
add_column('is_disabled', 'Boolean', writable: true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `domain` and `primary_domain` carry the same value; both are kept
|
|
56
|
+
# because Pylon returns both, and only the `domains` list is filterable.
|
|
57
|
+
# Neither is writable: `domains` is the list the API takes, and writing
|
|
58
|
+
# one of its two projections would leave the other stale.
|
|
59
|
+
def define_domain_fields
|
|
60
|
+
add_column('domain', 'String')
|
|
61
|
+
add_column('primary_domain', 'String')
|
|
62
|
+
add_column('domains', 'Json', writable: true)
|
|
63
|
+
add_column('tags', 'Json', writable: true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def define_ownership_fields
|
|
67
|
+
# Flattened from the nested `{ id: ..., email: ... }` object Pylon
|
|
68
|
+
# returns; a plain column, see `define_relations` above.
|
|
69
|
+
add_column('owner_id', 'String', writable: true)
|
|
70
|
+
# Read-only although the endpoint takes it: the column shows
|
|
71
|
+
# `{external_id, label}` objects, and the write shape the reference
|
|
72
|
+
# documents is not that one — writing one for the other would replace
|
|
73
|
+
# the ids of the account with something it cannot read.
|
|
74
|
+
add_column('external_ids', 'Json')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Both belong to the integrations Pylon syncs them from: `crm_settings`
|
|
78
|
+
# is absent from every write endpoint, and `channels` — which they do
|
|
79
|
+
# take — holds objects, like `external_ids` above.
|
|
80
|
+
def define_integration_fields
|
|
81
|
+
add_column('channels', 'Json')
|
|
82
|
+
add_column('crm_settings', 'Json')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def define_time_fields
|
|
86
|
+
%w[created_at updated_at latest_customer_activity_time].each { |field| add_column(field, 'Date') }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Account < CursorCollection
|
|
4
|
+
module Serializer
|
|
5
|
+
NATIVE_FIELDS = %w[id name type is_disabled domain primary_domain domains tags external_ids
|
|
6
|
+
channels crm_settings created_at updated_at
|
|
7
|
+
latest_customer_activity_time].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def serialize(account)
|
|
12
|
+
attrs = account.is_a?(Hash) ? account : {}
|
|
13
|
+
record = NATIVE_FIELDS.to_h { |field| [field, attrs[field]] }
|
|
14
|
+
record['owner_id'] = nested_id(attrs['owner'])
|
|
15
|
+
add_custom_field_values(record, attrs['custom_fields'])
|
|
16
|
+
record
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Account < CursorCollection
|
|
4
|
+
include SchemaDefinition
|
|
5
|
+
include Serializer
|
|
6
|
+
|
|
7
|
+
# Pylon reads an account's type back as `type` and takes it as
|
|
8
|
+
# `account_type`.
|
|
9
|
+
RENAMES = { 'type' => 'account_type' }.freeze
|
|
10
|
+
|
|
11
|
+
# An account is created enabled; only `PATCH /accounts/{id}` disables one.
|
|
12
|
+
UPDATE_ONLY = %w[is_disabled].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(datasource, custom_fields: [])
|
|
15
|
+
super(datasource, 'PylonAccount', custom_fields: custom_fields, searchable: true)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def filter_table = ApiFilters
|
|
21
|
+
|
|
22
|
+
def create_record(payload) = datasource.client.create_account(payload)
|
|
23
|
+
def update_record(id, payload) = datasource.client.update_account(id, payload)
|
|
24
|
+
def delete_record(id) = datasource.client.delete_account(id)
|
|
25
|
+
|
|
26
|
+
def update_only_fields = UPDATE_ONLY
|
|
27
|
+
def payload_renames = RENAMES
|
|
28
|
+
|
|
29
|
+
def unsortable_warning
|
|
30
|
+
'[forest_admin_datasource_pylon] PylonAccount cannot honour the requested order; neither GET /accounts ' \
|
|
31
|
+
'nor POST /accounts/search takes a sort parameter, so accounts come back in the order the API imposes.'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def search_page(limit:, cursor:, filter:, search_text:)
|
|
35
|
+
datasource.client.search_accounts(limit: limit, cursor: cursor, filter: filter, search_text: search_text)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list_page(limit:, cursor:)
|
|
39
|
+
datasource.client.list_accounts(limit: limit, cursor: cursor)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fetch_one(id)
|
|
43
|
+
datasource.client.fetch_account(id)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|