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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/README.md +179 -0
  4. data/Rakefile +6 -0
  5. data/forest_admin_datasource_pylon.gemspec +36 -0
  6. data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
  7. data/lib/forest_admin_datasource_pylon/client.rb +436 -0
  8. data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
  9. data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
  10. data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
  11. data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
  12. data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
  13. data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
  14. data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
  15. data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
  16. data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
  17. data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
  18. data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
  19. data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
  20. data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
  21. data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
  22. data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
  23. data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
  24. data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
  25. data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
  26. data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
  27. data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
  28. data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
  29. data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
  30. data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
  31. data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
  32. data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
  33. data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
  34. data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
  35. data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
  36. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
  37. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
  38. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
  39. data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
  40. data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
  41. data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
  42. data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
  43. data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
  44. data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
  45. data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
  46. data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
  47. data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
  48. data/lib/forest_admin_datasource_pylon/version.rb +3 -0
  49. data/lib/forest_admin_datasource_pylon.rb +70 -0
  50. metadata +152 -0
@@ -0,0 +1,417 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ # The write half of every Pylon collection. Included by `BaseCollection`, so
4
+ # a collection only declares the client calls, through the `*_record` hooks,
5
+ # and the fields Pylon accepts in one direction only. A hook left alone
6
+ # refuses the verb — no POST or DELETE on users, no DELETE on teams —
7
+ # instead of the contract's NotImplementedError, read by the agent as a 500.
8
+ #
9
+ # What may be written is `is_read_only` on the column, the way `api_filters`
10
+ # is what may be filtered: no second list to keep in step with the schema.
11
+ module Writes # rubocop:disable Metrics/ModuleLength
12
+ # Re-declared rather than borrowed from BaseCollection: a method defined
13
+ # here resolves a constant against this module and its ancestors, never
14
+ # against the class including it.
15
+ Filter = ForestAdminDatasourceToolkit::Components::Query::Filter
16
+ Page = ForestAdminDatasourceToolkit::Components::Query::Page
17
+ Projection = ForestAdminDatasourceToolkit::Components::Query::Projection
18
+ Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
19
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
20
+
21
+ # What one filter-driven update or delete may spend, in requests. Pylon
22
+ # writes one record per request, so a selection costing more than this is
23
+ # refused rather than written halfway.
24
+ #
25
+ # The cap is about the round-trips, not the quota: `RateLimiter` spaces the
26
+ # requests out inside the documented budget, which no pass this size comes
27
+ # near — but no throttling makes a hundred sequential writes a wait the
28
+ # operator watches, or a request the agent times out on first. Refusing is
29
+ # the honest answer where a partial write is not recoverable.
30
+ #
31
+ # The budget covers the whole pass, not its writes: where a record is read
32
+ # through its own endpoint, resolving the selection costs a request per
33
+ # record and reading a stored value costs another, so a cap counting the
34
+ # writes alone would let one pass spend three times this.
35
+ MAX_WRITE_REQUESTS = 20
36
+
37
+ # The refusal comes before the payload and the ids: everything on the way
38
+ # there answers with something else — a count, a field of the wrong
39
+ # direction — for a selection that was never the problem.
40
+ def create(_caller, data)
41
+ refuse_write('created') unless write_endpoint?(:create_record)
42
+
43
+ serialize(create_record(build_payload(writable_attributes(data), :create)))
44
+ rescue APIError => e
45
+ surface_write_rejection(e)
46
+ end
47
+
48
+ # What the patch may write is settled before the ids are, so a patch naming
49
+ # nothing writable is refused, and refused for what it names rather than
50
+ # for how many records it would have reached.
51
+ def update(caller, filter, patch)
52
+ refuse_write('updated') unless write_endpoint?(:update_record)
53
+
54
+ attributes = writable_attributes(patch)
55
+ refuse_unwritable_patch(patch) if attributes.empty? && names_fields?(patch)
56
+ # A patch naming nothing at all asked for nothing: the front sends one on
57
+ # a form whose every field is a relation, which `Update` writes through
58
+ # its own route.
59
+ return if attributes.empty?
60
+
61
+ ids = ids_for(caller, filter, extra_reads: stored_read?(attributes) ? 1 : 0)
62
+ return if ids.empty?
63
+
64
+ payload = build_payload(attributes, :update, caller: caller, ids: ids)
65
+ return if payload.empty?
66
+
67
+ write_each(ids, 'updated') { |id| update_record(id, payload) }
68
+ end
69
+
70
+ def delete(caller, filter)
71
+ refuse_write('deleted') unless write_endpoint?(:delete_record)
72
+
73
+ write_each(ids_for(caller, filter), 'deleted') { |id| delete_record(id) }
74
+ end
75
+
76
+ protected
77
+
78
+ # One Pylon write endpoint each, overridden by the collections having one.
79
+ def create_record(_payload) = refuse_write('created')
80
+ def update_record(_id, _payload) = refuse_write('updated')
81
+ def delete_record(_id) = refuse_write('deleted')
82
+
83
+ # The Forest schema carries a single read-only flag per column, so both
84
+ # directions offer these; the two lists tell them apart at write time.
85
+ def create_only_fields = [].freeze
86
+ def update_only_fields = [].freeze
87
+
88
+ # Columns whose Pylon write name differs from the one they are read under.
89
+ def payload_renames = {}.freeze
90
+
91
+ # What reading one record costs here. Nothing where the search endpoint
92
+ # filters `id`, a whole selection travelling in one request whatever its
93
+ # size; one request where an id is read through its own endpoint.
94
+ def requests_per_record_read = 0
95
+
96
+ # How many records one write may reach: the budget divided by what each of
97
+ # them costs — the write itself, plus the `reads` the path still owes it.
98
+ def max_write_targets(reads: 0)
99
+ MAX_WRITE_REQUESTS / (1 + (reads * requests_per_record_read))
100
+ end
101
+
102
+ # How many ids a filter may name before the resolution is refused rather
103
+ # than spent: the same reach, a named id being read before it is written
104
+ # to. `nil` is no bound, a read costing nothing per record.
105
+ def max_resolvable_ids(reads: 0)
106
+ return nil if requests_per_record_read.zero?
107
+
108
+ max_write_targets(reads: reads)
109
+ end
110
+
111
+ # The records a filter-driven write applies to: exact, or refused — the
112
+ # caller writes one request per id and reports success for the whole
113
+ # selection, so a subset may never be answered quietly.
114
+ #
115
+ # An `id equals`/`id in` filter alone — what the record detail and the bulk
116
+ # selection send — costs no request. Anything else goes through `list`.
117
+ def ids_for(caller, filter, extra_reads: 0)
118
+ tree = filter&.condition_tree
119
+ if (named = id_values(tree)) && no_search?(filter)
120
+ cap = max_write_targets(reads: extra_reads)
121
+ refuse_too_many_targets(named.size, cap) if named.size > cap
122
+ return named
123
+ end
124
+
125
+ # A selection naming ids is resolved by reading each of them; any other
126
+ # one by a single page of the collection's own read, whose cost does not
127
+ # grow with the count.
128
+ named_ids = filtered_ids(tree)
129
+ reads = extra_reads + (named_ids ? 1 : 0)
130
+ bound = named_ids && max_resolvable_ids(reads: reads)
131
+ refuse_unresolvable_selection(named_ids.size, bound) if bound && named_ids.size > bound
132
+
133
+ resolve_ids_by_list(caller, filter, reads: reads)
134
+ end
135
+
136
+ private
137
+
138
+ # The `*_record` hook is the declaration that the collection wired the
139
+ # endpoint, read here rather than repeated in a list of supported verbs.
140
+ def write_endpoint?(hook)
141
+ method(hook).owner != Writes
142
+ end
143
+
144
+ # One request per record, so a failure on the k-th leaves the k-1 before it
145
+ # written. The error names them: raising the API error alone reads as
146
+ # "nothing happened", and retrying on that reading would write them twice.
147
+ def write_each(ids, verb)
148
+ written = []
149
+
150
+ ids.each do |id|
151
+ yield id
152
+ written << id
153
+ rescue APIError => e
154
+ # Always raises, so nothing reaches the partial report below: with no
155
+ # record written the failure is the whole of what happened.
156
+ surface_write_rejection(e) if written.empty?
157
+
158
+ refuse_partial_write(verb, written, id, ids.size, e)
159
+ end
160
+ end
161
+
162
+ # A 4xx names something the operator did, and travels as the
163
+ # ValidationError whose message the agent surfaces where the APIError it
164
+ # arrived as would be answered with 'Unexpected error'. Anything else is
165
+ # Pylon or the network failing, which no edit of theirs would change.
166
+ #
167
+ # Only the write goes through here: a 4xx raised while resolving the
168
+ # selection still reaches them as a 500, reporting a read failure as a
169
+ # refused write being the worse of the two.
170
+ def surface_write_rejection(error)
171
+ raise error unless (400..499).cover?(error.status.to_i)
172
+
173
+ raise WriteRejectedError, error.message
174
+ end
175
+
176
+ # One record past the cap is asked for, so an overflow is seen rather than
177
+ # guessed from a full page.
178
+ def resolve_ids_by_list(caller, filter, reads:)
179
+ cap = max_write_targets(reads: reads)
180
+ window = Page.new(offset: 0, limit: cap + 1)
181
+ query = (filter || Filter.new).override(page: window)
182
+ records = list(caller, query, Projection.new(['id']))
183
+ refuse_unbounded_targets(cap) if records.size > cap
184
+
185
+ records.filter_map { |record| record['id'] }.uniq
186
+ end
187
+
188
+ # The ids a filter names, as a leaf of its own or inside a top-level `and`.
189
+ # Unlike `extract_id_lookup`, nothing is asserted about the rest of the
190
+ # tree — the leftovers travel to `list` — so this counts records *named*,
191
+ # never records the write applies to: what it bounds is the resolution.
192
+ #
193
+ # The `id` leaves of an `and` intersect, as they do there: this is the
194
+ # count a refusal is worded around, and the first of two would refuse a
195
+ # selection narrower than the number it names.
196
+ def filtered_ids(node)
197
+ named = id_values(node)
198
+ return named if named
199
+ return nil unless and_branch?(node)
200
+
201
+ named = Array(node.conditions).filter_map { |child| id_values(child) }
202
+ return nil if named.empty?
203
+
204
+ named.reduce(:&)
205
+ end
206
+
207
+ # The writable attributes, in the shape the endpoint takes them.
208
+ def build_payload(attributes, direction, caller: nil, ids: [])
209
+ attrs = honour_write_direction(attributes, direction, caller, ids)
210
+ # Pylon fills in what a create leaves out; on an update a nil is the
211
+ # operator clearing a value, so it travels.
212
+ attrs = attrs.compact if direction == :create
213
+
214
+ custom, native = split_custom_fields(attrs)
215
+ payload = native.transform_keys { |field| payload_renames.fetch(field, field) }
216
+ payload['custom_fields'] = custom unless custom.empty?
217
+ payload
218
+ end
219
+
220
+ # Everything else is dropped rather than refused: the front sends the
221
+ # fields of its form, and a read-only one reaching the payload is the
222
+ # agent's doing, not a request the operator made.
223
+ #
224
+ # What that leniency may not do is reduce a whole patch to nothing and let
225
+ # the edit be reported as performed -- see `refuse_unwritable_patch`. Only
226
+ # an update needs the guard: a create reduced to nothing still reaches
227
+ # Pylon, which refuses it with a reason of its own.
228
+ def writable_attributes(data)
229
+ attrs = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
230
+
231
+ attrs.select { |field, _value| writable_column?(field) }
232
+ end
233
+
234
+ def names_fields?(data) = data.is_a?(Hash) && data.any?
235
+
236
+ def writable_column?(field)
237
+ column = schema[:fields][field]
238
+
239
+ column&.type == 'Column' && !column.is_read_only
240
+ end
241
+
242
+ # Whether the patch will have `stored_values` read every record it reaches
243
+ # before a field of the wrong direction is dropped or refused, which the
244
+ # cap has to charge it for: see `ids_for`.
245
+ def stored_read?(attributes) = (attributes.keys & create_only_fields).any?
246
+
247
+ # A field of the other direction is dropped when it asks for nothing, and
248
+ # refused when the operator really changed it: answering an edit with a
249
+ # success Pylon did not perform is worse than an error naming the field.
250
+ #
251
+ # Only a create can tell without reading, Pylon filling it in with exactly
252
+ # what a blank value asks for. On an update the stored value is what
253
+ # settles it — an unchecked box is nothing over a stored `false`, and a
254
+ # real edit over a stored `true`.
255
+ def honour_write_direction(attrs, direction, caller, ids)
256
+ wrong = attrs.keys & (direction == :create ? update_only_fields : create_only_fields)
257
+ return attrs if wrong.empty?
258
+
259
+ asked = if direction == :create
260
+ wrong.reject { |field| blank_write_value?(attrs[field]) }
261
+ else
262
+ wrong - unchanged_fields(caller, ids, wrong, attrs)
263
+ end
264
+ refuse_wrong_direction(asked, direction) unless asked.empty?
265
+
266
+ attrs.except(*wrong)
267
+ end
268
+
269
+ # What a form sends for a field the operator never touched: no value at
270
+ # all, an unchecked box, an empty list. A `0` or a string is a value only
271
+ # the other endpoint could write.
272
+ def blank_write_value?(value)
273
+ return true if value.nil? || value == false
274
+ return value.empty? if value.respond_to?(:empty?)
275
+
276
+ false
277
+ end
278
+
279
+ # The wrong-direction fields already holding the value the patch asks for.
280
+ # One record the read did not hand back is enough to refuse them all:
281
+ # nothing here may claim a value is unchanged on a record it never read.
282
+ def unchanged_fields(caller, ids, fields, attrs)
283
+ return [] if fields.empty?
284
+
285
+ stored = stored_values(caller, ids, fields)
286
+ return [] if stored.size < ids.size
287
+
288
+ fields.select { |field| stored.all? { |record| same_write_value?(record[field], attrs[field]) } }
289
+ end
290
+
291
+ # Two blanks are the same state: Pylon returns a null where the form sends
292
+ # `false` or an empty string for the same untouched field. Strings are
293
+ # compared stripped, `body_html` travelling through an editor that may hand
294
+ # back the markup it was given re-indented — and refusing an edit nobody
295
+ # made is the one error the operator cannot act on.
296
+ def same_write_value?(stored, asked)
297
+ return true if blank_write_value?(stored) && blank_write_value?(asked)
298
+ return stored.to_s.strip == asked.to_s.strip if stored.is_a?(String) || asked.is_a?(String)
299
+
300
+ stored == asked
301
+ end
302
+
303
+ # Read only when the patch names a field of the wrong direction, and only
304
+ # for those fields. One request where the endpoint filters `id`, one per
305
+ # record where an id is read through its own endpoint — which the cap does
306
+ # charge the patch for, `stored_read?` declaring it before the ids are
307
+ # resolved.
308
+ #
309
+ # By id rather than through the caller's filter: that filter was already
310
+ # resolved into these ids, so re-running it would spend those requests
311
+ # twice and, carrying no page, walk every record it matches.
312
+ def stored_values(caller, ids, fields)
313
+ query = Filter.new(condition_tree: Leaf.new('id', Operators::IN, ids),
314
+ page: Page.new(offset: 0, limit: ids.size))
315
+
316
+ list(caller, query, Projection.new(['id'] + fields))
317
+ end
318
+
319
+ # Pylon reads its custom fields back as a map indexed by slug and writes
320
+ # them as a list, `values` for a multi-value field and `value` for every
321
+ # other — a select by the slug of its option, what the Enum advertises.
322
+ def split_custom_fields(attrs)
323
+ by_column = custom_fields_by_column
324
+ entries = []
325
+
326
+ native = attrs.each_with_object({}) do |(field, value), rest|
327
+ custom_field = by_column[field]
328
+ custom_field ? entries << custom_field_entry(custom_field, value) : rest[field] = value
329
+ end
330
+
331
+ [entries, native]
332
+ end
333
+
334
+ def custom_fields_by_column
335
+ @custom_fields_by_column ||= custom_fields.to_h { |field| [field[:column_name], field] }
336
+ end
337
+
338
+ def custom_field_entry(custom_field, value)
339
+ slug = custom_field[:column_name]
340
+ return { 'slug' => slug, 'values' => Array(value) } if custom_field[:multi_value]
341
+
342
+ { 'slug' => slug, 'value' => value }
343
+ end
344
+
345
+ def refuse_write(verb)
346
+ raise UnsupportedWriteError,
347
+ "A #{name} record cannot be #{verb}: the Pylon API exposes no endpoint for it."
348
+ end
349
+
350
+ # Every offending field at once: refusing them one at a time would have the
351
+ # operator undo one, retry, and learn about the next.
352
+ def refuse_wrong_direction(fields, direction)
353
+ them = fields.one? ? 'it' : 'them'
354
+ detail = if direction == :create
355
+ "Pylon only accepts #{them} on an existing record: create the record, then edit it."
356
+ else
357
+ "Pylon only accepts #{them} when the record is created, and exposes no endpoint to change " \
358
+ "#{them} afterwards."
359
+ end
360
+
361
+ named = fields.map { |field| "'#{field}'" }.join(', ')
362
+ raise UnsupportedWriteError, "#{named} cannot be set here on a #{name}: #{detail}"
363
+ end
364
+
365
+ # A patch every key of which was dropped would write nothing and answer
366
+ # the operator with a successful edit: the route then re-reads the record
367
+ # and hands back the values it already had, so what they see is their
368
+ # change reverting with no reason given. Refused instead, naming the keys.
369
+ #
370
+ # Nothing is refused while anything remains writable -- a form resending a
371
+ # read-only column alongside a real edit performs the edit, and the column
372
+ # is dropped as it always was.
373
+ def refuse_unwritable_patch(patch)
374
+ named = patch.keys.map { |field| "'#{field}'" }.join(', ')
375
+ raise UnsupportedWriteError,
376
+ "This edit of a #{name} names only #{named}, and none of them can be written here: a read-only " \
377
+ 'column, or a field this collection does not declare. Nothing would reach Pylon, and answering ' \
378
+ 'the edit with a success would report a change that never landed.'
379
+ end
380
+
381
+ # The count is exact here, the filter having named the ids.
382
+ def refuse_too_many_targets(count, cap)
383
+ refuse_write_reach("applies to #{count} #{name} records, more than the #{cap} one pass covers")
384
+ end
385
+
386
+ # The resolution only knows the selection overflows: reporting the size of
387
+ # its window would name 21 records to a selection holding thousands.
388
+ def refuse_unbounded_targets(cap)
389
+ refuse_write_reach("applies to more than the #{cap} #{name} records one pass covers")
390
+ end
391
+
392
+ def refuse_write_reach(reach)
393
+ raise UnsupportedWriteError,
394
+ "This write #{reach}: Pylon writes one record per request, and a write stopping halfway — on a " \
395
+ 'timeout, or on the first record Pylon refuses — would report a success it did not perform. ' \
396
+ 'Narrow the selection to reach the records past this point.'
397
+ end
398
+
399
+ # How many of the named ids the rest of the filter matches is unknown here,
400
+ # so the count is reported as what it is: records named.
401
+ def refuse_unresolvable_selection(count, bound)
402
+ raise UnsupportedWriteError,
403
+ "This write names #{count} #{name} records and filters them further, which #{name} answers with " \
404
+ "one request per named record, on top of the one each write costs: more than the #{bound} one " \
405
+ 'pass covers. Select fewer records, or drop the other conditions to write the ones named.'
406
+ end
407
+
408
+ def refuse_partial_write(verb, written, failed_id, total, error)
409
+ raise PartialWriteError,
410
+ "#{written.size} of #{total} #{name} records were #{verb} and then '#{failed_id}' failed: " \
411
+ "#{error.message}. The records already #{verb} are #{written.join(", ")}, and they stay " \
412
+ "#{verb} — the ones after them were left untouched. Retry the write on the untouched records " \
413
+ "alone: retrying the whole selection would perform it twice on the ones already #{verb}."
414
+ end
415
+ end
416
+ end
417
+ end
@@ -0,0 +1,84 @@
1
+ module ForestAdminDatasourcePylon
2
+ class Configuration
3
+ DEFAULT_BASE_URL = 'https://api.usepylon.com'.freeze
4
+
5
+ attr_reader :api_key, :base_url, :open_timeout, :timeout, :retry_policy, :rate_limiter,
6
+ :boot_open_timeout, :boot_timeout, :boot_retry_policy
7
+
8
+ # `rate_limiter: nil` takes the throttling out of the stack, leaving the 429
9
+ # retry as the only rate-limit handling. For the deployment metering on its
10
+ # own side, or the one that would rather see the 429.
11
+ #
12
+ # The `boot_` trio governs what the datasource reads while it is being
13
+ # constructed, where the wait is a Rails boot the operator sits through
14
+ # rather than a request that has already returned a page.
15
+ def initialize(api_key:, base_url: nil, open_timeout: 5, timeout: 30, retry_policy: RetryPolicy.new,
16
+ rate_limiter: RateLimiter.new, boot_open_timeout: 3, boot_timeout: 10,
17
+ boot_retry_policy: RetryPolicy.boot)
18
+ @api_key = api_key
19
+ @base_url = base_url || DEFAULT_BASE_URL
20
+ @open_timeout = open_timeout
21
+ @timeout = timeout
22
+ @retry_policy = retry_policy
23
+ @rate_limiter = rate_limiter
24
+ @boot_open_timeout = boot_open_timeout
25
+ @boot_timeout = boot_timeout
26
+ @boot_retry_policy = boot_retry_policy
27
+ validate!
28
+ end
29
+
30
+ # Pylon exposes unversioned paths (`/issues`, `/me`) directly under the host.
31
+ def url
32
+ @base_url.chomp('/')
33
+ end
34
+
35
+ # Whatever precedes the endpoint in the path, for a base url mounted under a
36
+ # subpath — an egress proxy, or a mock server. Empty against the API itself.
37
+ # `RateLimits` is keyed on the endpoint, so this has to come off a path
38
+ # before the table is asked: left on, every anchored rule misses and the
39
+ # whole datasource meters in one fallback bucket.
40
+ def base_path
41
+ @base_path ||= URI.parse(url).path
42
+ end
43
+
44
+ # The base url as something safe to print. `api_key` is not the only
45
+ # credential a Configuration holds: the url is operator-supplied, and an
46
+ # egress proxy fronting Pylon is spelled `https://user:pass@proxy.internal`,
47
+ # so its user-info is a password too. Whoever prints the url prints this.
48
+ #
49
+ # Cut with a regexp rather than through `URI`: nothing validates that the
50
+ # base url parses, and an `inspect` raising on the way to a Rails error page
51
+ # would replace the page with its own failure.
52
+ def redacted_url
53
+ url.sub(%r{://[^/@]+@}, '://[FILTERED]@')
54
+ end
55
+
56
+ # `api_key` is a bearer token, and nothing prints a Configuration on
57
+ # purpose: what reaches an `inspect` is a Rails error page, or a
58
+ # `logger.debug` of something holding one. The default would put the token
59
+ # in clear there.
60
+ #
61
+ # One of three, not the whole of it: the token also rides in the headers of
62
+ # the client's Faraday connections, which print them in clear, so `Client`
63
+ # and `Datasource` mask their own. Together they cut every path from an
64
+ # object this package hands out to the credential.
65
+ def inspect
66
+ "#<#{self.class.name} base_url=#{redacted_url.inspect} api_key=[FILTERED]>"
67
+ end
68
+
69
+ private
70
+
71
+ def validate!
72
+ missing = []
73
+ missing << 'api_key' if blank?(@api_key)
74
+ return if missing.empty?
75
+
76
+ raise ConfigurationError,
77
+ "ForestAdminDatasourcePylon missing required config: #{missing.join(", ")}"
78
+ end
79
+
80
+ def blank?(value)
81
+ value.nil? || value.to_s.strip.empty?
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,53 @@
1
+ module ForestAdminDatasourcePylon
2
+ class Datasource < ForestAdminDatasourceToolkit::Datasource
3
+ attr_reader :client, :configuration
4
+
5
+ def initialize(api_key:, **options)
6
+ super()
7
+ @configuration = Configuration.new(api_key: api_key, **options)
8
+ @client = Client.new(@configuration)
9
+
10
+ register_collections
11
+ end
12
+
13
+ # The datasource is what a Rails error page or a `logger.debug` is likeliest
14
+ # to print, and it holds the client whose connections carry the bearer
15
+ # token. Every collection reaches that token the same way, through the
16
+ # `@datasource` the toolkit's Collection keeps, so cutting the chain here
17
+ # covers them too -- and spares the recursive dump the default `inspect`
18
+ # walks into, a datasource and its collections pointing at each other.
19
+ def inspect
20
+ "#<#{self.class.name} collections=#{collections.keys.inspect}>"
21
+ end
22
+
23
+ private
24
+
25
+ # The five collections are registered together: each one declares relations
26
+ # pointing at the others, and a relation whose foreign collection is missing
27
+ # is a schema the agent refuses to boot on.
28
+ #
29
+ # Their custom fields are introspected here, one call per object type, as
30
+ # Pylon indexes its definitions by object type and asks for one on every
31
+ # call. Each entry stays on the collection it belongs to: a custom field is
32
+ # filtered through the very slug it is read by, which the collection's
33
+ # `api_filters` already carries, so there is no datasource-wide mapping to
34
+ # hold — and two Pylon datasources in the same agent share nothing.
35
+ #
36
+ # An introspection that fails costs the custom columns, not the datasource:
37
+ # `fetch_custom_fields` degrades and the agent boots on the native schema.
38
+ # The first failure also stands for the object types after it, each call
39
+ # being bounded per request rather than across the three — see
40
+ # `CustomFieldsIntrospector`.
41
+ def register_collections
42
+ custom_fields = Schema::CustomFieldsIntrospector.new(@client)
43
+
44
+ add_collection(Collections::Issue.new(self, custom_fields: custom_fields.issue_custom_fields))
45
+ add_collection(Collections::Account.new(self, custom_fields: custom_fields.account_custom_fields))
46
+ add_collection(Collections::Contact.new(self, custom_fields: custom_fields.contact_custom_fields))
47
+ # Pylon carries custom fields on issues, accounts and contacts only:
48
+ # neither an agent nor a team has any.
49
+ add_collection(Collections::User.new(self))
50
+ add_collection(Collections::Team.new(self))
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,20 @@
1
+ module ForestAdminDatasourcePylon
2
+ # The closed sets `POST /issues` and `PATCH /issues/{id}` document, shared by
3
+ # the plugins building forms over them.
4
+ module IssueEnums
5
+ # Accepted on a create, and absent from every read: Pylon never returns the
6
+ # priority of an issue, which is why no column carries it.
7
+ PRIORITY = %w[urgent high medium low].freeze
8
+
9
+ # Where the first message of a created issue is delivered. `internal` is the
10
+ # absence of a delivery, and travels as no `destination_metadata` at all.
11
+ DESTINATION = %w[email slack in_app_chat customer_portal sms whatsapp internal].freeze
12
+
13
+ INTERNAL_DESTINATION = 'internal'.freeze
14
+
15
+ # The one state every organization has: the others Pylon ships, and the
16
+ # custom ones defined on top of them, are named by the option that writes
17
+ # them rather than listed here.
18
+ CLOSED_STATE = 'closed'.freeze
19
+ end
20
+ end
@@ -0,0 +1,103 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Pagination
3
+ # Forest asks for an offset/limit window; Pylon only knows how to hand out
4
+ # the next page of a cursor. Bridging the two means walking pages until the
5
+ # window is covered, then slicing. Deep offsets therefore cost one request
6
+ # per page, which is why the walk is capped: the requests are sequential —
7
+ # a cursor is only known once the page before it came back — so an unbounded
8
+ # walk is a list view the operator waits on, page after page, well before it
9
+ # is a quota `/issues/search` grants 120 requests a minute of.
10
+ class CursorWalker
11
+ MAX_PAGES = 20
12
+ MAX_RECORDS = 5_000
13
+
14
+ def initialize(max_pages: MAX_PAGES, max_records: MAX_RECORDS)
15
+ @max_pages = max_pages
16
+ @max_records = max_records
17
+ end
18
+
19
+ # Yields `(limit, cursor)` and expects a Client::SearchPage back.
20
+ #
21
+ # A nil limit asks for every record past the offset: the walk then runs
22
+ # until Pylon says there is no page left, or until a cap stops it. That
23
+ # distinction is the whole point of accepting nil rather than a limit
24
+ # standing in for "everything": a walk told to collect a thousand records
25
+ # stops at a thousand having covered the window it was given, and reports
26
+ # nothing, while a walk told to collect everything and stopped by a cap
27
+ # knows it is handing back less than it was asked for, and says so.
28
+ def walk(offset:, limit:, &page_source)
29
+ offset = offset.to_i.clamp(0, nil)
30
+ limit = limit&.to_i
31
+ return [] if limit && !limit.positive?
32
+
33
+ records = collect(offset, limit, &page_source)
34
+
35
+ limit ? (records[offset, limit] || []) : records.drop(offset)
36
+ end
37
+
38
+ private
39
+
40
+ # The walk itself: pages are collected until the window is covered, the
41
+ # source says there is nothing left, or a cap stops it. The slicing is
42
+ # `walk`'s, this only decides how far to go.
43
+ def collect(offset, limit)
44
+ needed = limit && (offset + limit)
45
+ records = []
46
+ cursor = nil
47
+ seen = Set.new
48
+ pages = 0
49
+
50
+ loop do
51
+ page = yield(batch_size(needed, records.size), cursor)
52
+ records.concat(page.records)
53
+ pages += 1
54
+
55
+ break if stop?(page, seen)
56
+ break if needed && records.size >= needed
57
+
58
+ if capped?(pages, records.size)
59
+ log_truncation(offset: offset, limit: limit, pages: pages, collected: records.size)
60
+ break
61
+ end
62
+
63
+ cursor = page.next_cursor
64
+ end
65
+
66
+ records
67
+ end
68
+
69
+ # An empty page, a cursor that does not move and a cursor already followed
70
+ # all stop the walk; Pylon does none of the three today, but a walk driven
71
+ # by a remote value stops on its own terms rather than on the caps only.
72
+ #
73
+ # The whole set is kept rather than the previous cursor alone, like
74
+ # `Client#collect_pages`: a cycle wider than one page would otherwise
75
+ # collect the same pages over and over until a cap cut it short, and hand
76
+ # back the duplicates as records.
77
+ def stop?(page, seen)
78
+ page.next_cursor.nil? || page.records.empty? || !seen.add?(page.next_cursor)
79
+ end
80
+
81
+ def capped?(pages, collected)
82
+ pages >= @max_pages || collected >= @max_records
83
+ end
84
+
85
+ # Bounded by the record budget left, and by the window still missing when
86
+ # there is one, so the walk never collects past @max_records.
87
+ def batch_size(needed, collected)
88
+ budget = @max_records - collected
89
+ budget = [needed - collected, budget].min if needed
90
+ budget.clamp(1, Client::MAX_SEARCH_LIMIT)
91
+ end
92
+
93
+ def log_truncation(offset:, limit:, pages:, collected:)
94
+ window = limit ? "offset=#{offset} limit=#{limit}" : "every record past offset=#{offset}"
95
+ ForestAdminDatasourcePylon.logger.warn(
96
+ "[forest_admin_datasource_pylon] Stopped paginating after #{pages} page(s) / #{collected} record(s) " \
97
+ "while fetching #{window}; results are truncated. " \
98
+ 'Narrow the filter to reach records past this point.'
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end