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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/LICENSE +202 -0
- data/README.md +811 -0
- data/Rakefile +6 -0
- data/exe/forest_admin_intercom_probe +287 -0
- data/forest_admin_datasource_intercom.gemspec +38 -0
- data/lib/forest_admin_datasource_intercom/cache.rb +84 -0
- data/lib/forest_admin_datasource_intercom/client.rb +546 -0
- data/lib/forest_admin_datasource_intercom/collections/admin.rb +83 -0
- data/lib/forest_admin_datasource_intercom/collections/base_collection.rb +161 -0
- data/lib/forest_admin_datasource_intercom/collections/company/serializer.rb +44 -0
- data/lib/forest_admin_datasource_intercom/collections/company.rb +69 -0
- data/lib/forest_admin_datasource_intercom/collections/contact/serializer.rb +106 -0
- data/lib/forest_admin_datasource_intercom/collections/contact.rb +266 -0
- data/lib/forest_admin_datasource_intercom/collections/contact_identity.rb +77 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/serializer.rb +75 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation.rb +153 -0
- data/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb +363 -0
- data/lib/forest_admin_datasource_intercom/collections/custom_attributes.rb +55 -0
- data/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb +196 -0
- data/lib/forest_admin_datasource_intercom/collections/offset_collection.rb +311 -0
- data/lib/forest_admin_datasource_intercom/collections/records_by_id.rb +97 -0
- data/lib/forest_admin_datasource_intercom/collections/relations.rb +294 -0
- data/lib/forest_admin_datasource_intercom/collections/team.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/team_membership.rb +51 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/derived_columns.rb +130 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/serializer.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket.rb +143 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_state.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_type.rb +46 -0
- data/lib/forest_admin_datasource_intercom/collections/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/configuration.rb +226 -0
- data/lib/forest_admin_datasource_intercom/datasource.rb +78 -0
- data/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb +123 -0
- data/lib/forest_admin_datasource_intercom/query/caller_zone.rb +48 -0
- data/lib/forest_admin_datasource_intercom/query/condition_tree_translator.rb +169 -0
- data/lib/forest_admin_datasource_intercom/query/day_bounds.rb +75 -0
- data/lib/forest_admin_datasource_intercom/query/filter_value.rb +177 -0
- data/lib/forest_admin_datasource_intercom/query/operator_table.rb +54 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.rb +225 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.yml +663 -0
- data/lib/forest_admin_datasource_intercom/rate_limiter.rb +148 -0
- data/lib/forest_admin_datasource_intercom/retry_policy.rb +95 -0
- data/lib/forest_admin_datasource_intercom/schema/attribute_naming.rb +52 -0
- data/lib/forest_admin_datasource_intercom/schema/data_attributes_introspector.rb +88 -0
- data/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb +133 -0
- data/lib/forest_admin_datasource_intercom/throttle.rb +19 -0
- data/lib/forest_admin_datasource_intercom/version.rb +3 -0
- data/lib/forest_admin_datasource_intercom.rb +57 -0
- 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
|