forest_admin_datasource_intercom 1.49.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/LICENSE +202 -0
  4. data/README.md +811 -0
  5. data/Rakefile +6 -0
  6. data/exe/forest_admin_intercom_probe +287 -0
  7. data/forest_admin_datasource_intercom.gemspec +38 -0
  8. data/lib/forest_admin_datasource_intercom/cache.rb +84 -0
  9. data/lib/forest_admin_datasource_intercom/client.rb +546 -0
  10. data/lib/forest_admin_datasource_intercom/collections/admin.rb +83 -0
  11. data/lib/forest_admin_datasource_intercom/collections/base_collection.rb +161 -0
  12. data/lib/forest_admin_datasource_intercom/collections/company/serializer.rb +44 -0
  13. data/lib/forest_admin_datasource_intercom/collections/company.rb +69 -0
  14. data/lib/forest_admin_datasource_intercom/collections/contact/serializer.rb +106 -0
  15. data/lib/forest_admin_datasource_intercom/collections/contact.rb +266 -0
  16. data/lib/forest_admin_datasource_intercom/collections/contact_identity.rb +77 -0
  17. data/lib/forest_admin_datasource_intercom/collections/conversation/serializer.rb +75 -0
  18. data/lib/forest_admin_datasource_intercom/collections/conversation/timeline.rb +40 -0
  19. data/lib/forest_admin_datasource_intercom/collections/conversation.rb +153 -0
  20. data/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb +363 -0
  21. data/lib/forest_admin_datasource_intercom/collections/custom_attributes.rb +55 -0
  22. data/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb +196 -0
  23. data/lib/forest_admin_datasource_intercom/collections/offset_collection.rb +311 -0
  24. data/lib/forest_admin_datasource_intercom/collections/records_by_id.rb +97 -0
  25. data/lib/forest_admin_datasource_intercom/collections/relations.rb +294 -0
  26. data/lib/forest_admin_datasource_intercom/collections/team.rb +70 -0
  27. data/lib/forest_admin_datasource_intercom/collections/team_membership.rb +51 -0
  28. data/lib/forest_admin_datasource_intercom/collections/ticket/derived_columns.rb +130 -0
  29. data/lib/forest_admin_datasource_intercom/collections/ticket/serializer.rb +70 -0
  30. data/lib/forest_admin_datasource_intercom/collections/ticket.rb +143 -0
  31. data/lib/forest_admin_datasource_intercom/collections/ticket_state.rb +40 -0
  32. data/lib/forest_admin_datasource_intercom/collections/ticket_type.rb +46 -0
  33. data/lib/forest_admin_datasource_intercom/collections/timeline.rb +40 -0
  34. data/lib/forest_admin_datasource_intercom/configuration.rb +226 -0
  35. data/lib/forest_admin_datasource_intercom/datasource.rb +78 -0
  36. data/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb +123 -0
  37. data/lib/forest_admin_datasource_intercom/query/caller_zone.rb +48 -0
  38. data/lib/forest_admin_datasource_intercom/query/condition_tree_translator.rb +169 -0
  39. data/lib/forest_admin_datasource_intercom/query/day_bounds.rb +75 -0
  40. data/lib/forest_admin_datasource_intercom/query/filter_value.rb +177 -0
  41. data/lib/forest_admin_datasource_intercom/query/operator_table.rb +54 -0
  42. data/lib/forest_admin_datasource_intercom/query/search_fields.rb +225 -0
  43. data/lib/forest_admin_datasource_intercom/query/search_fields.yml +663 -0
  44. data/lib/forest_admin_datasource_intercom/rate_limiter.rb +148 -0
  45. data/lib/forest_admin_datasource_intercom/retry_policy.rb +95 -0
  46. data/lib/forest_admin_datasource_intercom/schema/attribute_naming.rb +52 -0
  47. data/lib/forest_admin_datasource_intercom/schema/data_attributes_introspector.rb +88 -0
  48. data/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb +133 -0
  49. data/lib/forest_admin_datasource_intercom/throttle.rb +19 -0
  50. data/lib/forest_admin_datasource_intercom/version.rb +3 -0
  51. data/lib/forest_admin_datasource_intercom.rb +57 -0
  52. metadata +180 -0
@@ -0,0 +1,311 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # `POST /companies/list` paginates by offset. Companies have no search endpoint
4
+ # (`/companies/scroll` allows one open scroll per app, expiring in a minute), so filters are
5
+ # limited to exact lookups and everything else is refused. Long by line count only: refusals.
6
+ class OffsetCollection < BaseCollection # rubocop:disable Metrics/ClassLength
7
+ include RecordsById
8
+
9
+ # What one page holds when the read names no window: a relation resolving
10
+ # its target, a segment, a customizer. A list view always names one.
11
+ UNBOUNDED_PAGE_SIZE = Client::MAX_PER_PAGE
12
+
13
+ # And how many such pages are read before the answer is cut short. The
14
+ # figure only ever applies to a read with no window of its own.
15
+ MAX_COLLECTED_PAGES = 10
16
+
17
+ # What a read that *does* name a window may collect before it is cut
18
+ # short. A window is its own bound, so the page count above must not apply
19
+ # to it: a window needing eleven pages would come back one page short
20
+ # while the warning blamed it for naming no window -- which it did name.
21
+ # This is the backstop for a window nothing sane asked for, and it is the
22
+ # cursor walker's own record budget, that tier being bounded the same way.
23
+ MAX_COLLECTED_RECORDS = Pagination::CursorWalker::MAX_RECORDS
24
+
25
+ # How many records of this collection one relation read may resolve.
26
+ # Higher than `MAX_ID_READS`, which bounds a single request batch, and
27
+ # deliberately above every page size a list view offers: a page naming one
28
+ # account per row resolves, and a read naming more -- an export, a
29
+ # segment resolved whole -- is refused by name rather than answered with
30
+ # a nil where an account exists. There is no bulk read for a company, so
31
+ # each one costs a request and the figure is what an operator waits for.
32
+ MAX_RELATION_READS = 100
33
+
34
+ # Search is declared only to be refused by name: left to the customizer,
35
+ # it would be emulated as equalities on columns no lookup answers.
36
+ def initialize(datasource, name)
37
+ super
38
+ enable_count
39
+ enable_search
40
+ end
41
+
42
+ def ids_per_read = MAX_ID_READS
43
+ def max_resolvable_ids = MAX_RELATION_READS
44
+
45
+ def list(caller, filter, projection)
46
+ warn_ignored_sort(filter&.sort)
47
+
48
+ client.with_read_scope do
49
+ records = fetch_records(filter)
50
+ serialized = records.map { |record| serialize(record) }
51
+ rows = serialized.map { |record| project(record, projection) }
52
+
53
+ embed_relations(caller, serialized, rows, projection)
54
+ rows
55
+ end
56
+ end
57
+
58
+ # Count only, and never a group: `total_count` is exact on every listing,
59
+ # while grouping the page in hand would answer a fraction as if it were
60
+ # the whole.
61
+ def aggregate(_caller, filter, aggregation, _limit = nil)
62
+ refuse_unsupported_aggregation!(aggregation)
63
+
64
+ [{ 'group' => {}, 'value' => count_records(filter) }]
65
+ end
66
+
67
+ protected
68
+
69
+ # The endpoint that lists the collection by offset, the one that reads a
70
+ # record, and the lookups Intercom answers on the listing path.
71
+ def list_path = raise(NotImplementedError, "#{self.class} did not implement list_path")
72
+ def record_endpoint = raise(NotImplementedError, "#{self.class} did not implement record_endpoint")
73
+ def lookup_path = record_endpoint
74
+ def lookups = {}
75
+
76
+ def serialize(_entity) = raise(NotImplementedError, "#{self.class} did not implement serialize")
77
+
78
+ # A column advertises a filter only where Intercom looks the collection up
79
+ # by it, so a column cannot offer a filter this tier would then refuse.
80
+ # The primary key is the exception and it is not a filter: `id equals X`
81
+ # and `id in [...]` are answered by the record endpoint.
82
+ #
83
+ # Nothing is sortable: the listing takes no order and ordering a page in
84
+ # hand would order a fraction of the collection.
85
+ def add_column(name, type, is_primary_key: false)
86
+ add_field(name, ColumnSchema.new(column_type: type,
87
+ filter_operators: column_operators(name, is_primary_key),
88
+ is_primary_key: is_primary_key,
89
+ is_read_only: true,
90
+ is_sortable: false,
91
+ is_groupable: false))
92
+ end
93
+
94
+ private
95
+
96
+ def column_operators(name, is_primary_key)
97
+ return [Operators::EQUAL, Operators::IN] if is_primary_key
98
+
99
+ lookups.key?(name) ? [Operators::EQUAL] : []
100
+ end
101
+
102
+ def fetch_records(filter)
103
+ refuse_search! unless blank_search?(filter)
104
+ return [] if matches_nothing?(filter)
105
+
106
+ ids = id_lookup(filter)
107
+ return records_by_ids(page_window(ids, filter)) if ids
108
+
109
+ lookup = lookup_condition(filter)
110
+ return page_window(looked_up_records(lookup), filter) if lookup
111
+
112
+ refuse_condition!(filter.condition_tree) unless filter&.condition_tree.nil?
113
+
114
+ listed_records(filter)
115
+ end
116
+
117
+ def count_records(filter)
118
+ refuse_search! unless blank_search?(filter)
119
+ return 0 if matches_nothing?(filter)
120
+
121
+ ids = id_lookup(filter)
122
+ return count_by_ids(ids) if ids
123
+
124
+ lookup = lookup_condition(filter)
125
+ return looked_up_count(lookup) if lookup
126
+
127
+ refuse_condition!(filter.condition_tree) unless filter&.condition_tree.nil?
128
+
129
+ exact_count(read_offset_page(page: 1, per_page: 1))
130
+ end
131
+
132
+ # The window a list view asked for, read as the page Intercom counts from
133
+ # 1. An offset that does not fall on a page boundary is served by reading
134
+ # the page it lands in and the ones after it until the window is filled --
135
+ # exactly, rather than by rounding the offset to something the API likes.
136
+ def listed_records(filter)
137
+ offset, limit = window(filter&.page)
138
+ return [] if limit&.zero?
139
+
140
+ per_page = Client.bounded_per_page(limit || UNBOUNDED_PAGE_SIZE)
141
+ skip = offset % per_page
142
+
143
+ collected = collect_pages(first_page: (offset / per_page) + 1, per_page: per_page,
144
+ wanted: limit && (skip + limit))
145
+
146
+ limit ? (collected[skip, limit] || []) : collected.drop(skip)
147
+ end
148
+
149
+ def collect_pages(first_page:, per_page:, wanted:)
150
+ records = []
151
+ page = first_page
152
+ read = 0
153
+
154
+ loop do
155
+ answer = read_offset_page(page: page, per_page: per_page)
156
+ records.concat(answer.records)
157
+ read += 1
158
+ break if last_page?(answer, page) || (wanted && records.size >= wanted)
159
+ break if cap_reached?(read, records.size, wanted)
160
+
161
+ page += 1
162
+ end
163
+
164
+ # Intercom numbers its pages by `per_page`, so the last one read cannot
165
+ # be asked smaller: what it brings past the budget is cut here instead.
166
+ wanted ? records.first(MAX_COLLECTED_RECORDS) : records
167
+ end
168
+
169
+ def read_offset_page(page:, per_page:)
170
+ client.offset_page(list_path, page: page, per_page: per_page)
171
+ end
172
+
173
+ def last_page?(answer, page)
174
+ answer.records.empty? || (answer.total_pages && page >= answer.total_pages)
175
+ end
176
+
177
+ # Which of the two caps applies, and the reason that goes with it: a read
178
+ # with no window of its own is stopped by the page count, a read that
179
+ # named one by the record budget. Telling a list view it named no window
180
+ # is exactly the confusion these two keep apart.
181
+ def cap_reached?(read, collected, wanted)
182
+ if wanted
183
+ return false if collected < MAX_COLLECTED_RECORDS
184
+
185
+ warn_capped("#{collected} record(s)",
186
+ 'this read named a window larger than one answer may hold')
187
+ else
188
+ return false if read < MAX_COLLECTED_PAGES
189
+
190
+ warn_capped("#{read} page(s) / #{collected} record(s)",
191
+ 'this read named no window of its own, and a list view always does')
192
+ end
193
+
194
+ true
195
+ end
196
+
197
+ def warn_capped(reached, reason)
198
+ ForestAdminDatasourceIntercom.logger.warn(
199
+ "[forest_admin_datasource_intercom] Stopped reading #{name} after #{reached}; the rest is left out: " \
200
+ "#{reason}."
201
+ )
202
+ end
203
+
204
+ # A filter with no page asks for every record it matched; nil is how the
205
+ # window says so.
206
+ def window(page)
207
+ return [0, nil] if page.nil?
208
+
209
+ [page.offset.to_i.clamp(0, nil), page_limit(page)]
210
+ end
211
+
212
+ # `Or []`, which `ConditionTreeFactory.match_ids` builds from no ids at all
213
+ # -- a live-query segment that returned none.
214
+ def matches_nothing?(filter)
215
+ tree = filter&.condition_tree
216
+ tree.is_a?(Branch) && tree.aggregator.to_s.casecmp('or').zero? && Array(tree.conditions).empty?
217
+ end
218
+
219
+ def lookup_condition(filter)
220
+ tree = filter&.condition_tree
221
+ return nil unless tree.is_a?(Leaf) && tree.operator == Operators::EQUAL
222
+
223
+ parameter = lookups[tree.field.to_s]
224
+ parameter && { parameter => tree.value.to_s }
225
+ end
226
+
227
+ # Read as a single page; overflow is reported. Intercom answers a lookup matching nothing
228
+ # with a 404, read here as an empty page.
229
+ def looked_up_records(params)
230
+ answer = lookup(params)
231
+ return [] unless answer
232
+
233
+ warn_truncated_lookup(params) if answer.next_cursor
234
+ answer.records
235
+ end
236
+
237
+ # Intercom's own count when it gave one. A lookup spread over several
238
+ # pages without one cannot be counted from the first page alone.
239
+ def looked_up_count(params)
240
+ answer = lookup(params)
241
+ return 0 unless answer
242
+ return answer.total_count if answer.total_count
243
+ return exact_count(answer) if answer.next_cursor
244
+
245
+ answer.records.size
246
+ end
247
+
248
+ def lookup(params)
249
+ client.lookup_page(lookup_path, params: params)
250
+ rescue APIError => e
251
+ raise unless e.status == 404
252
+
253
+ nil
254
+ end
255
+
256
+ def refuse_search!
257
+ raise UnsupportedOperatorError,
258
+ "#{name} cannot answer a free-text search: Intercom exposes no search endpoint for this collection " \
259
+ "and looks a record up by id, #{lookups.keys.join(", ")} alone, one exact value at a time. Filter " \
260
+ 'on one of those instead of searching.'
261
+ end
262
+
263
+ def refuse_condition!(tree)
264
+ leaves = []
265
+ tree.some_leaf do |leaf|
266
+ leaves << leaf
267
+ false
268
+ end
269
+ offender = leaves.find { |leaf| !published?(leaf) }
270
+ refused = if offender
271
+ "filter #{offender.field.inspect}"
272
+ else
273
+ "combine conditions on #{leaves.map(&:field).uniq.join(", ")}"
274
+ end
275
+
276
+ raise UnsupportedOperatorError,
277
+ "#{name} cannot #{refused}: Intercom exposes no search endpoint for this " \
278
+ "collection and looks a record up by #{lookups.keys.join(", ")} alone -- one exact value at a time, " \
279
+ 'with no combination and no other operator. Filter on one of those, or reach the record from the ' \
280
+ 'collection next door.'
281
+ end
282
+
283
+ def published?(leaf)
284
+ schema = fields[leaf.field]
285
+ schema.respond_to?(:filter_operators) && schema.filter_operators.include?(leaf.operator)
286
+ end
287
+
288
+ # Intercom takes no order on this listing at all -- there is no parameter
289
+ # for one -- so an order asked for and not applied is reported here or
290
+ # nowhere. The ascending primary-key sort the agent injects when a request
291
+ # names none is not one of those.
292
+ def warn_ignored_sort(sort)
293
+ clauses = Array(sort)
294
+ return if clauses.empty? || default_pk_sort?(clauses)
295
+
296
+ ForestAdminDatasourceIntercom.logger.warn(
297
+ "[forest_admin_datasource_intercom] #{name} was asked to sort on " \
298
+ "#{clauses.map { |clause| sort_field(clause) }.join(", ")}, and Intercom takes no order " \
299
+ 'on this listing. The rows come back in the order the API imposes.'
300
+ )
301
+ end
302
+
303
+ def warn_truncated_lookup(params)
304
+ ForestAdminDatasourceIntercom.logger.warn(
305
+ "[forest_admin_datasource_intercom] #{name} looked up #{params.inspect} and Intercom advertised more " \
306
+ 'records than one page holds; the rest is left out. This lookup is meant for a key that names one record.'
307
+ )
308
+ end
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,97 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # `id equals X` / `id in [...]` answered by the record endpoint, plus exact counting -- shared
4
+ # by the two paginated tiers, where a count over collected pages would answer a fraction.
5
+ module RecordsById
6
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
7
+ Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
8
+ Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation
9
+
10
+ # How many records a bulk read by id may fetch. Intercom exposes no "read
11
+ # these records" endpoint, so a set of ids is one request each -- the
12
+ # fan-out is bounded rather than turned into a rate limit halfway through
13
+ # a page. The collection that reads its ids in bulk raises the figure.
14
+ MAX_ID_READS = 25
15
+
16
+ protected
17
+
18
+ # The endpoint one record is read from.
19
+ def record_endpoint = raise(NotImplementedError, "#{self.class} did not implement record_endpoint")
20
+
21
+ # What rides along on that read. `display_as=plaintext` for the tiers
22
+ # whose records carry bodies written by end customers; nothing elsewhere.
23
+ def record_read_params = {}
24
+
25
+ def max_id_reads = MAX_ID_READS
26
+
27
+ # A bare primary-key leaf only: answering an `and` carrying a scope, or a search, with the
28
+ # ids alone would serve records it excludes. Deduplicated so an id is not read twice.
29
+ def id_lookup(filter)
30
+ tree = filter&.condition_tree
31
+ return nil unless tree.is_a?(Leaf) && tree.field.to_s == primary_key
32
+ return nil unless blank_search?(filter)
33
+
34
+ case tree.operator
35
+ when Operators::EQUAL then [tree.value].compact.map(&:to_s).uniq
36
+ when Operators::IN then Array(tree.value).compact.map(&:to_s).uniq
37
+ end
38
+ end
39
+
40
+ # A record the operator can no longer reach -- deleted, or outside the
41
+ # token's scope -- reads as "no record" rather than as a failed page.
42
+ def records_by_ids(ids)
43
+ refuse_id_reads!(ids.size, 'list') if ids.size > max_id_reads
44
+
45
+ client.fetch_records(record_endpoint, ids, params: record_read_params).compact
46
+ end
47
+
48
+ # How many of a set of ids name a record, which means reading them:
49
+ # Intercom has no "how many of these exist" endpoint. Past what a bulk
50
+ # read may fetch the count is refused rather than answered with the number
51
+ # the truncation left -- both paginated tiers advertise an exact count,
52
+ # and "25" where the question named forty records is not one.
53
+ def count_by_ids(ids)
54
+ refuse_id_reads!(ids.size, 'count') if ids.size > max_id_reads
55
+
56
+ records_by_ids(ids).size
57
+ end
58
+
59
+ # The count Intercom answered, or nothing at all. Counting the pages a
60
+ # read collected would answer a fraction of the collection as if it were
61
+ # the whole of it, which is the one thing the paginated tiers do not do.
62
+ def exact_count(page)
63
+ return page.total_count if page.total_count
64
+
65
+ raise UnsupportedOperatorError,
66
+ "#{name} cannot be counted: Intercom answered this listing without a total_count, and counting the " \
67
+ 'pages the agent read would answer a fraction of the collection as if it were the whole of it.'
68
+ end
69
+
70
+ def refuse_unsupported_aggregation!(aggregation)
71
+ return if aggregation.is_a?(Aggregation) && aggregation.operation.to_s.casecmp('count').zero? &&
72
+ Array(aggregation.groups).empty? && aggregation.field.nil?
73
+
74
+ raise UnsupportedOperatorError,
75
+ "#{name} can only be counted: Intercom exposes no aggregate endpoint, and grouping or summing the " \
76
+ 'pages the agent read would answer a fraction of the collection as if it were the whole of it. ' \
77
+ 'Chart it on a collection read whole, or wait for the bounded group-by of the reporting lot.'
78
+ end
79
+
80
+ # The same refusal for a list as for a count: the first `max_id_reads`
81
+ # rows of a longer set of ids look like the whole of it.
82
+ def refuse_id_reads!(asked, verb)
83
+ raise UnsupportedOperatorError,
84
+ "#{name} cannot #{verb} #{asked} records by id: a set of ids is read from Intercom record by " \
85
+ "record or in small batches, and this reads #{max_id_reads} at most. Answering with the ones it read " \
86
+ 'would leave the rest out of an answer that looks whole. Ask for fewer records at a time, or use a ' \
87
+ 'filter this collection answers instead of a list of ids.'
88
+ end
89
+
90
+ def blank_search?(filter)
91
+ search = filter.respond_to?(:search) ? filter.search : nil
92
+
93
+ search.nil? || search.to_s.strip.empty?
94
+ end
95
+ end
96
+ end
97
+ end