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,546 @@
1
+ module ForestAdminDatasourceIntercom
2
+ # Hand-written on Faraday rather than the `intercom` gem, to keep raw payloads, quota headers and error bodies.
3
+ # Long by line count only: one method per endpoint plus shared envelope and error handling.
4
+ class Client # rubocop:disable Metrics/ClassLength
5
+ # `per_page=200` is refused with `invalid_per_page` -- "must be an integer
6
+ # between 0 and 150". There is no silent downgrade, so a page size is bounded
7
+ # before it is sent or the list view breaks rather than shrinks.
8
+ MAX_PER_PAGE = 150
9
+
10
+ # Bounds `fetch_all`, which asks for a whole reference collection rather than
11
+ # a window: those endpoints answer in one response, so reaching this many
12
+ # pages means Intercom started paginating on its own and the read is spending
13
+ # more than the answer is worth.
14
+ MAX_COLLECTED_PAGES = 10
15
+
16
+ # `next_cursor` is nil as soon as Intercom stops advertising a next page, so
17
+ # callers never have to know how the absence is spelled on the wire.
18
+ # `total_count` is exact, filter included, which is what makes Forest's
19
+ # record counter and its "number of" charts one request each.
20
+ # `total_pages` is filled by the one endpoint that paginates by offset and
21
+ # nil everywhere else: a cursor page has no notion of how many there are.
22
+ Page = Struct.new(:records, :next_cursor, :total_count, :total_pages, keyword_init: true)
23
+
24
+ def initialize(configuration)
25
+ @configuration = configuration
26
+ # Named per instance rather than per class: two datasources are two
27
+ # workspaces, and a scope shared between them would hand one the other's
28
+ # answer. A handful of clients live in a process, so a symbol each costs
29
+ # nothing.
30
+ @scope_key = :"forest_admin_datasource_intercom_read_scope_#{object_id}"
31
+ # Both connections built here rather than memoized on first use. Neither
32
+ # reaches the network -- and what a lazy reader would hand the thread
33
+ # that lost the race is no longer a spare object but a pool of open
34
+ # sockets, nothing left to close it. The adapter is resolved once for the
35
+ # two of them.
36
+ @adapter = resolve_adapter
37
+ @connection = build_connection(retry_policy: @configuration.retry_policy,
38
+ timeout: @configuration.timeout,
39
+ open_timeout: @configuration.open_timeout)
40
+ # For what is read while the datasource is being constructed -- the
41
+ # custom-attribute introspection above all: short timeouts and one quick
42
+ # retry, so a slow Intercom cannot turn a Rails boot into minutes of
43
+ # waiting. Built apart from the one above, which keeps the patience every
44
+ # later request is entitled to.
45
+ @boot_connection = build_connection(retry_policy: @configuration.boot_retry_policy,
46
+ timeout: @configuration.boot_timeout,
47
+ open_timeout: @configuration.boot_open_timeout)
48
+ end
49
+
50
+ # Deduplicates reads while one page is built (e.g. ticket contacts read for a column and a relation).
51
+ # Fiber-local (`Thread.current[]`) so concurrent requests on a fiber scheduler never share a scope;
52
+ # re-entrant; callers share returned objects, which must not be mutated.
53
+ def with_read_scope
54
+ return yield unless Thread.current[@scope_key].nil?
55
+
56
+ Thread.current[@scope_key] = {}
57
+ begin
58
+ yield
59
+ ensure
60
+ Thread.current[@scope_key] = nil
61
+ end
62
+ end
63
+
64
+ # Health check: the admin the token belongs to, plus its workspace. Enough
65
+ # to prove the credentials are usable, and the one call that verifies the
66
+ # pinned API version was honoured -- Intercom echoes the version it served
67
+ # in a response header.
68
+ #
69
+ # `boot: true` runs it on the short-timeout connection, for a caller
70
+ # checking the token while the agent is still starting.
71
+ def me(boot: false)
72
+ must_succeed('me') do
73
+ response = get('me', boot: boot)
74
+ verify_pinned_version(response)
75
+ response.body
76
+ end
77
+ end
78
+
79
+ # `/tickets/search` answers under `tickets`, not `data` (measured), so the caller names its `list_key`.
80
+ def list_page(path, per_page:, starting_after: nil, params: {}, list_key: 'data', boot: false)
81
+ query = params.merge('per_page' => self.class.bounded_per_page(per_page))
82
+ query['starting_after'] = starting_after unless blank?(starting_after)
83
+
84
+ must_succeed(path) { to_page(get(path, query, boot: boot).body, path, list_key) }
85
+ end
86
+
87
+ # `params` stays in the query string (`display_as`). `sort` is honoured by `/contacts/search` alone:
88
+ # the other search endpoints silently ignore it (measured).
89
+ def search_page(path, query:, per_page:, starting_after: nil, list_key: 'data', params: {}, sort: nil)
90
+ pagination = { 'per_page' => self.class.bounded_per_page(per_page) }
91
+ pagination['starting_after'] = starting_after unless blank?(starting_after)
92
+ body = { 'query' => query, 'pagination' => pagination }
93
+ body['sort'] = sort_clause(sort) if sort
94
+
95
+ within_read_scope(['search', path, body, params, list_key]) do
96
+ must_succeed(path) { to_page(post(path, body, params: params).body, path, list_key) }
97
+ end
98
+ end
99
+
100
+ # One page of an endpoint that paginates by **offset** rather than by
101
+ # cursor. `POST /companies/list` is the only one, and it is what lets the
102
+ # Companies collection answer page 7 of a list view with one request
103
+ # instead of walking six pages to reach it. Intercom counts pages from 1.
104
+ def offset_page(path, page:, per_page:, params: {}, list_key: 'data')
105
+ query = params.merge('page' => [page.to_i, 1].max,
106
+ 'per_page' => self.class.bounded_per_page(per_page))
107
+
108
+ must_succeed(path) { to_offset_page(post(path, {}, params: query).body, path, list_key) }
109
+ end
110
+
111
+ # An exact lookup, and the shape surprise that comes with it: `GET
112
+ # /companies?name=` answers the company itself where `?tag_id=` answers a
113
+ # list. A record is read here as a page of one, so a caller writes one route
114
+ # rather than testing the envelope.
115
+ def lookup_page(path, params:, list_key: 'data')
116
+ must_succeed(path) do
117
+ body = get(path, params).body
118
+ next Page.new(records: [body], next_cursor: nil, total_count: 1) if single_record?(body, list_key)
119
+
120
+ to_page(body, path, list_key)
121
+ end
122
+ end
123
+
124
+ # One record from its own endpoint. Raises on a 404 like on any other
125
+ # failure: what a missing record means -- a stale link, a record outside the
126
+ # token's scope, a deletion -- is the caller's to decide, not the client's.
127
+ def fetch_record(path, id, params: {}, boot: false)
128
+ within_read_scope(record_key(path, id, params)) { read_record(path, id, params, boot: boot) }
129
+ end
130
+
131
+ # Records read one request each, `concurrent_reads` at a time, in the order
132
+ # of `ids`; nil for one Intercom no longer answers. The read scope is read
133
+ # and written here, on the calling thread: a worker sees neither, a Hash
134
+ # shared between threads being no store.
135
+ def fetch_records(path, ids, params: {})
136
+ scope = Thread.current[@scope_key]
137
+ found = {}
138
+ pending = []
139
+ ids.uniq.each do |id|
140
+ key = record_key(path, id, params)
141
+ scope&.key?(key) ? found[id] = scope[key] : pending << id
142
+ end
143
+
144
+ read_concurrently(pending) { |id| record_or_nil(path, id, params) }.each do |id, record|
145
+ found[id] = record
146
+ scope[record_key(path, id, params)] = record if scope && record
147
+ end
148
+ ids.map { |id| found[id] }
149
+ end
150
+
151
+ # Every record of an unpaginated reference endpoint, held in `Cache` for the configured window.
152
+ # A cursor is still followed defensively. `boot` only picks the connection, so it is not part of the key;
153
+ # the list is frozen shallowly so no caller mutates what the next page reads. A list cut at the page cap
154
+ # is not held: served for the whole window, it would be counted as the workspace without a word.
155
+ # Also scoped to the page, so a through-collection and its target read one list even uncached.
156
+ def fetch_all(path, list_key: 'data', params: {}, boot: false)
157
+ key = ['fetch_all', path, list_key, params]
158
+ within_read_scope(key) do
159
+ complete = true
160
+ @configuration.reference_cache.fetch(key, store: -> { complete }) do
161
+ must_succeed(path) do
162
+ records, complete = collect_pages(path, list_key: list_key, params: params, boot: boot)
163
+ records.freeze
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ # The page size Intercom accepts, whatever was asked for.
170
+ def self.bounded_per_page(size)
171
+ value = size.to_i
172
+ return 1 if value < 1
173
+
174
+ [value, MAX_PER_PAGE].min
175
+ end
176
+
177
+ # The client holds the connections whose headers carry the access token in
178
+ # clear, and Faraday prints those headers on `inspect`.
179
+ def inspect
180
+ "#<#{self.class.name} url=#{@configuration.redacted_url.inspect}>"
181
+ end
182
+
183
+ private
184
+
185
+ # Readers rather than builders: both connections are built in the
186
+ # constructor, where what distinguishes them is argued.
187
+ attr_reader :connection, :boot_connection
188
+
189
+ # The answer already given for `key` while this page was being built, or the
190
+ # block's. Outside a scope -- a boot read, a customizer calling the client -- the block runs
191
+ # as it always did: nothing is remembered where nothing opened a scope.
192
+ #
193
+ # A block that raises writes nothing, so a failed read is not replayed to
194
+ # the rest of the page as an answer.
195
+ def within_read_scope(key)
196
+ scope = Thread.current[@scope_key]
197
+ return yield if scope.nil?
198
+ return scope[key] if scope.key?(key)
199
+
200
+ scope[key] = yield
201
+ end
202
+
203
+ def record_key(path, id, params) = ['record', path, id, params]
204
+
205
+ def read_record(path, id, params, boot: false)
206
+ operation = "#{path}/#{id}"
207
+
208
+ must_succeed(operation) do
209
+ body = get("#{path}/#{Faraday::Utils.escape(id)}", params, boot: boot).body
210
+ body.is_a?(Hash) ? body : refuse_body_shape(operation, 'the response is not a record')
211
+ end
212
+ end
213
+
214
+ def record_or_nil(path, id, params)
215
+ read_record(path, id, params)
216
+ rescue APIError => e
217
+ raise unless e.status == 404
218
+
219
+ nil
220
+ end
221
+
222
+ # `items` mapped through the block, at most `concurrent_reads` at a time.
223
+ # Inline at a width of one, which is the behaviour of before this existed.
224
+ # Wider, a failure stops the workers taking new items, every worker is
225
+ # joined, and the failure raised is the first in the order of `items` --
226
+ # the same one whatever order the responses came back in.
227
+ def read_concurrently(items, &read)
228
+ width = [@configuration.concurrent_reads, items.size].min
229
+ return items.to_h { |item| [item, yield(item)] } if width <= 1
230
+
231
+ outcomes = run_workers(items, width, &read)
232
+ failure = items.lazy.map { |item| outcomes[item] }.find { |outcome| outcome&.first == :error }
233
+ raise failure.last if failure
234
+
235
+ items.to_h { |item| [item, outcomes[item].last] }
236
+ end
237
+
238
+ def run_workers(items, width, &read)
239
+ queue = Queue.new
240
+ items.each { |item| queue << item }
241
+ queue.close
242
+ outcomes = {}
243
+ lock = Mutex.new
244
+
245
+ Array.new(width) do
246
+ Thread.new do
247
+ while (item = queue.pop)
248
+ outcome = attempt(item, &read)
249
+ lock.synchronize { outcomes[item] = outcome }
250
+ queue.clear if outcome.first == :error
251
+ end
252
+ end
253
+ end.each(&:join)
254
+ outcomes
255
+ end
256
+
257
+ def attempt(item)
258
+ [:ok, yield(item)]
259
+ rescue StandardError => e
260
+ [:error, e]
261
+ end
262
+
263
+ # The raw response rather than its body: the quota headers are read by the
264
+ # throttle, and the version echo by `verify_pinned_version`.
265
+ def get(path, params = nil, boot: false)
266
+ (boot ? boot_connection : connection).get(path, params)
267
+ end
268
+
269
+ def post(path, body, params: {}, boot: false)
270
+ http = boot ? boot_connection : connection
271
+
272
+ http.post(path) do |request|
273
+ request.params.update(params) unless params.nil? || params.empty?
274
+ request.body = body
275
+ end
276
+ end
277
+
278
+ # Intercom serves the version its workspace defaults to when the pin is not
279
+ # honoured, and the payloads differ between versions. The echo is the only
280
+ # way to notice, and noticing at boot is worth more than a schema that
281
+ # drifts silently -- so this reports rather than raises: the agent still runs
282
+ # against a version it did not ask for, which is better than not running.
283
+ def verify_pinned_version(response)
284
+ served = response.headers['intercom-version'] if response.respond_to?(:headers)
285
+ return if served.nil? || served.to_s == @configuration.api_version
286
+
287
+ ForestAdminDatasourceIntercom.logger.warn(
288
+ "[forest_admin_datasource_intercom] asked Intercom for API version #{@configuration.api_version} " \
289
+ "and it served #{served}. Payload shapes may differ from the ones this datasource expects; check the " \
290
+ "workspace's default version in the Developer Hub."
291
+ )
292
+ end
293
+
294
+ # Intercom spells an order `{ "field": "...", "order": "descending" }`,
295
+ # and answers `data_invalid` on anything else -- so the clause is written
296
+ # here rather than by the caller, whose vocabulary is Forest's.
297
+ def sort_clause(sort)
298
+ { 'field' => sort[:field].to_s, 'order' => sort[:ascending] == false ? 'descending' : 'ascending' }
299
+ end
300
+
301
+ # A record rather than a listing: no list under either key, and an id where
302
+ # a record carries one. An empty listing is not one of these -- it answers
303
+ # `data` as an empty array, which is a page of nothing rather than a record.
304
+ def single_record?(body, list_key)
305
+ body.is_a?(Hash) && !body[list_key].is_a?(Array) && !body['data'].is_a?(Array) && body.key?('id')
306
+ end
307
+
308
+ def collect_pages(path, list_key:, params:, boot:)
309
+ records = []
310
+ cursor = nil
311
+ pages = 0
312
+
313
+ loop do
314
+ query = cursor.nil? ? params : params.merge('starting_after' => cursor)
315
+ body = get(path, query.empty? ? nil : query, boot: boot).body
316
+ records.concat(extract_entities(body, path, list_key))
317
+ pages += 1
318
+ cursor = next_cursor(body, path)
319
+ break if cursor.nil?
320
+
321
+ if pages >= MAX_COLLECTED_PAGES
322
+ log_collection_cap(path, pages, records.size)
323
+ return [records, false]
324
+ end
325
+ end
326
+
327
+ [records, true]
328
+ end
329
+
330
+ # The records under the key the endpoint uses, or under `data`. Anything
331
+ # else is refused rather than read as an empty page: `Array()` would turn the
332
+ # envelope into `[key, value]` pairs a collection would serialize into rows
333
+ # holding nothing -- a page that looks answered and is empty -- and a
334
+ # reference collection silently read as empty is a state column with no
335
+ # values and an assignee shown as a raw id.
336
+ def extract_entities(body, operation, list_key)
337
+ refuse_body_shape(operation, 'the body is empty') if body.nil? || body == ''
338
+
339
+ entities = body.is_a?(Hash) ? (body[list_key] || body['data']) : nil
340
+ return entities if entities.is_a?(Array)
341
+
342
+ detail = list_key == 'data' ? "'data' is not a list" : "neither '#{list_key}' nor 'data' is a list"
343
+ refuse_body_shape(operation, detail)
344
+ end
345
+
346
+ def log_collection_cap(path, pages, collected)
347
+ ForestAdminDatasourceIntercom.logger.warn(
348
+ "[forest_admin_datasource_intercom] Stopped reading #{path} after #{pages} page(s) / " \
349
+ "#{collected} record(s); the rest is left out, and this partial list is not cached. This endpoint is " \
350
+ 'read whole on purpose, so a workspace this large needs the collection bounded rather than listed.'
351
+ )
352
+ end
353
+
354
+ # Intercom wraps a listing in `{ "type": "list", "data": [...],
355
+ # "total_count": N, "pages": { "next": { "starting_after": "..." } } }`.
356
+ def to_page(body, operation, list_key)
357
+ Page.new(records: extract_entities(body, operation, list_key),
358
+ next_cursor: next_cursor(body, operation),
359
+ total_count: extract_count(body),
360
+ total_pages: extract_total_pages(body))
361
+ end
362
+
363
+ # The offset tier addresses the page after this one by number, and Intercom
364
+ # advertises it the same way -- `pages.next` there is a page, not a cursor.
365
+ # Reading it as one is what made the Companies list refuse a perfectly
366
+ # ordinary answer, so this page is built without it.
367
+ def to_offset_page(body, operation, list_key)
368
+ Page.new(records: extract_entities(body, operation, list_key),
369
+ next_cursor: nil,
370
+ total_count: extract_count(body),
371
+ total_pages: extract_total_pages(body))
372
+ end
373
+
374
+ # Absent on the last page. An older API version (served despite the pin) spells it as a url, so the
375
+ # cursor is read from its query string; an unreadable advertised cursor is refused, not taken as the end.
376
+ def next_cursor(body, operation)
377
+ advertised = body.is_a?(Hash) && body['pages'].is_a?(Hash) ? body['pages']['next'] : nil
378
+ return nil if advertised.nil?
379
+
380
+ cursor = case advertised
381
+ when Hash then advertised['starting_after']
382
+ when String then cursor_from_url(advertised)
383
+ end
384
+
385
+ presence(cursor) || refuse_body_shape(operation, "'pages.next' carries no cursor this can follow")
386
+ end
387
+
388
+ def cursor_from_url(url)
389
+ Faraday::Utils.parse_query(URI.parse(url).query.to_s)['starting_after']
390
+ rescue URI::InvalidURIError
391
+ nil
392
+ end
393
+
394
+ # nil rather than 0 when Intercom sends no count: zero is an answer, and
395
+ # this is the absence of one.
396
+ def extract_count(body)
397
+ count = body['total_count'] if body.is_a?(Hash)
398
+ count.is_a?(Numeric) ? count.to_i : nil
399
+ end
400
+
401
+ # How many pages the offset tier has to read through, when the endpoint
402
+ # counts them. nil on a cursor page, which counts nothing.
403
+ def extract_total_pages(body)
404
+ pages = body['pages'] if body.is_a?(Hash)
405
+ total = pages['total_pages'] if pages.is_a?(Hash)
406
+ total.is_a?(Numeric) ? total.to_i : nil
407
+ end
408
+
409
+ def refuse_body_shape(operation, detail)
410
+ raise APIError.new("Intercom API call failed: #{operation}: unexpected response shape, #{detail}", status: nil)
411
+ end
412
+
413
+ def presence(value)
414
+ blank?(value) ? nil : value
415
+ end
416
+
417
+ def blank?(value)
418
+ value.nil? || value.to_s.empty?
419
+ end
420
+
421
+ # `cause: nil`: the chained original would quote the payload, the URL, or the request with its bearer token.
422
+ def must_succeed(operation)
423
+ yield
424
+ rescue Faraday::Error, JSON::ParserError => e
425
+ raise api_error(operation, e), cause: nil
426
+ rescue APIError
427
+ # Already mapped, with its status intact; re-wrapping would erase it --
428
+ # a 404 read as "no such record" rather than as a failure, above all.
429
+ raise
430
+ rescue StandardError => e
431
+ # A transport failure the adapter did not wrap is an API failure; anything else is a bug here, and
432
+ # mapping it would have the callers that degrade log it as a transient Intercom outage.
433
+ raise unless RetryPolicy.connection_failure?(e)
434
+
435
+ raise APIError, "Intercom API call failed: #{operation}: #{e.class}: #{e.message}", cause: nil
436
+ end
437
+
438
+ # Builds an APIError preserving the HTTP status and Intercom's own error body
439
+ # so a caller can show the operator the real reason instead of "failed".
440
+ def api_error(operation, error)
441
+ response = response_of(error)
442
+ status = response[:status]
443
+ body = parse_body(response[:body])
444
+
445
+ APIError.new("Intercom API call failed: #{operation}: #{failure_detail(error, status, body)}",
446
+ status: status, body: body)
447
+ end
448
+
449
+ # Faraday hands the status and the body back in a plain hash on most errors
450
+ # and in its own `Env` on a parsing error; both answer `[]`.
451
+ def response_of(error)
452
+ response = error.respond_to?(:response) ? error.response : nil
453
+ return { status: nil, body: nil } unless response.respond_to?(:[])
454
+
455
+ { status: response[:status], body: response[:body] }
456
+ end
457
+
458
+ # A body that could not be parsed is named, never quoted: the parser's own
459
+ # message opens with the characters it choked on, and on a 200 those are the
460
+ # payload -- a conversation body, most of the time (R10).
461
+ def failure_detail(error, status, body)
462
+ return unreadable_detail(status) if parse_failure?(error)
463
+ return "#{error.class}: #{error.message}" unless status
464
+
465
+ "HTTP #{status} #{error_message(body)}".strip
466
+ end
467
+
468
+ def unreadable_detail(status)
469
+ detail = 'the response could not be read as JSON'
470
+ status ? "#{detail} (HTTP #{status})" : detail
471
+ end
472
+
473
+ def parse_failure?(error)
474
+ error.is_a?(Faraday::ParsingError) || error.is_a?(JSON::ParserError)
475
+ end
476
+
477
+ # The request id is appended after truncation since Intercom support asks for it. A non-error body is
478
+ # never echoed -- it may be a payload such as a conversation body (R10); only its size is reported.
479
+ def error_message(parsed)
480
+ return "(unreadable body, #{parsed.to_s.bytesize} bytes)" unless parsed.is_a?(Hash)
481
+
482
+ message = join_errors(parsed['errors'])
483
+ message = parsed.to_json if message.empty?
484
+
485
+ append_request_id(message[0, 500], parsed['request_id'])
486
+ end
487
+
488
+ def join_errors(errors)
489
+ Array(errors).filter_map do |error|
490
+ next error unless error.is_a?(Hash)
491
+
492
+ [error['code'], error['message']].compact.join(': ')
493
+ end.join('; ')
494
+ end
495
+
496
+ def append_request_id(message, request_id)
497
+ return message unless request_id
498
+
499
+ "#{message} (request_id: #{request_id})"
500
+ end
501
+
502
+ def parse_body(body)
503
+ return body unless body.is_a?(String) && !body.empty?
504
+
505
+ JSON.parse(body)
506
+ rescue JSON::ParserError
507
+ body
508
+ end
509
+
510
+ # Resolved once, at boot: an adapter this process cannot load is refused
511
+ # there rather than on the first request. The persistent adapter registers
512
+ # itself only once its gem is required.
513
+ def resolve_adapter
514
+ name, options = Array(@configuration.adapter || Configuration::DEFAULT_ADAPTER)
515
+ require 'faraday/net_http_persistent' if name == :net_http_persistent
516
+ Faraday::Adapter.lookup_middleware(name)
517
+
518
+ [name, options || {}]
519
+ rescue LoadError, Faraday::Error
520
+ raise ConfigurationError,
521
+ "ForestAdminDatasourceIntercom adapter #{name.inspect} cannot be loaded by this process. Add the gem " \
522
+ 'that provides it, or set an adapter this process can load.'
523
+ end
524
+
525
+ # `raise_error` outside the JSON parser (raises with a parsed body); `retry` innermost to see raw 429s;
526
+ # the throttle inside `retry` so replays wait and 429 headers reach the one shared limiter.
527
+ def build_connection(retry_policy:, timeout:, open_timeout:)
528
+ name, options = @adapter
529
+
530
+ Faraday.new(url: @configuration.url) do |f|
531
+ f.request :json
532
+ f.response :raise_error
533
+ f.response :json
534
+ f.request :retry, **retry_policy.to_faraday_options
535
+ f.use Throttle, limiter: @configuration.rate_limiter if @configuration.rate_limiter
536
+ f.adapter(name, **options)
537
+ f.headers['Authorization'] = "Bearer #{@configuration.access_token}"
538
+ f.headers['Accept'] = 'application/json'
539
+ f.headers['Intercom-Version'] = @configuration.api_version
540
+ f.headers['User-Agent'] = "forest_admin_datasource_intercom/#{VERSION}"
541
+ f.options.open_timeout = open_timeout
542
+ f.options.timeout = timeout
543
+ end
544
+ end
545
+ end
546
+ end
@@ -0,0 +1,83 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # The teammates of the workspace: who a conversation or a ticket is assigned
4
+ # to. Without this collection an assignee is a raw id on every row.
5
+ class Admin < FetchAllCollection
6
+ def initialize(datasource)
7
+ super(datasource, 'IntercomAdmin')
8
+ end
9
+
10
+ protected
11
+
12
+ # `/admins` puts its records under `admins`, not under the `data` envelope
13
+ # the paginated listings use.
14
+ def fetch_all
15
+ client.fetch_all('admins', list_key: 'admins')
16
+ end
17
+
18
+ def serialize(admin)
19
+ attrs = admin.is_a?(Hash) ? admin : {}
20
+
21
+ { 'id' => stringify_id(attrs['id']),
22
+ 'name' => attrs['name'],
23
+ 'email' => attrs['email'],
24
+ 'job_title' => attrs['job_title'],
25
+ 'away_mode_enabled' => attrs['away_mode_enabled'],
26
+ 'away_mode_reassign' => attrs['away_mode_reassign'],
27
+ 'has_inbox_seat' => attrs['has_inbox_seat'],
28
+ # Not a column: it is what the names below are read from, and what the
29
+ # membership collection turns into the relation.
30
+ 'team_ids' => Array(attrs['team_ids']).map { |id| stringify_id(id) },
31
+ 'team_names' => nil }
32
+ end
33
+
34
+ # The teams the teammate belongs to, by name, and only when a projection
35
+ # asked for them: one read of `/teams` for the whole page. A token that
36
+ # cannot read them costs the column and nothing else.
37
+ def enrich(records, rows, projection)
38
+ return unless column_asked?(projection, 'team_names')
39
+
40
+ names = team_names
41
+ return if names.nil?
42
+
43
+ records.each_with_index do |record, index|
44
+ rows[index]['team_names'] = Array(record['team_ids']).filter_map { |id| names[id] }
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def team_names
51
+ client.fetch_all('teams', list_key: 'teams')
52
+ .to_h { |team| [stringify_id(team['id']), team['name']] }
53
+ rescue APIError => e
54
+ ForestAdminDatasourceIntercom.logger.warn(
55
+ "[forest_admin_datasource_intercom] #{name} could not read the teams of the workspace (HTTP " \
56
+ "#{e.status || "-"}); the names are left unknown. The relation to IntercomTeam is unaffected."
57
+ )
58
+ nil
59
+ end
60
+
61
+ def define_schema
62
+ add_column('id', 'String', is_primary_key: true)
63
+ add_column('name', 'String')
64
+ add_column('email', 'String')
65
+ add_column('job_title', 'String')
66
+ # Whether the teammate is away, and whether their conversations get
67
+ # reassigned while they are: the two an ops lead looks at before
68
+ # assigning anything.
69
+ add_column('away_mode_enabled', 'Boolean')
70
+ add_column('away_mode_reassign', 'Boolean')
71
+ add_column('has_inbox_seat', 'Boolean')
72
+ # A list, so neither filterable nor sortable -- as the array of ids it
73
+ # replaces was. It reads the teammate without a join; the relation below
74
+ # is what navigates it, through the membership collection that gives the
75
+ # many-to-many the join Intercom does not expose.
76
+ add_column('team_names', 'Json')
77
+ add_many_to_many('teams', foreign_collection: 'IntercomTeam',
78
+ through_collection: 'IntercomTeamMembership',
79
+ origin_key: 'admin_id', foreign_key: 'team_id')
80
+ end
81
+ end
82
+ end
83
+ end