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,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
|