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,148 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
# Paces on Intercom's x-ratelimit-* headers. Workspace budget is 25 000/min but allocated in 10s windows
|
|
3
|
+
# (measured x-ratelimit-limit 1667), so the instantaneous rate matters. Paces, does not queue: callers
|
|
4
|
+
# woken at a reset can burst unmetered until the next response; the 429 retry behind this absorbs it.
|
|
5
|
+
class RateLimiter
|
|
6
|
+
# Intercom's allocation window. Only used as the ceiling below: the reset
|
|
7
|
+
# instant itself always comes from the response.
|
|
8
|
+
WINDOW = 10.0
|
|
9
|
+
|
|
10
|
+
# How far past the reset a request may be held. A little over one window, so
|
|
11
|
+
# a full window can be waited out, and no more: past this the wait is not
|
|
12
|
+
# Intercom's window emptying but a clock disagreeing.
|
|
13
|
+
DEFAULT_MAX_WAIT = 12.0
|
|
14
|
+
|
|
15
|
+
attr_reader :max_wait
|
|
16
|
+
|
|
17
|
+
def initialize(max_wait: DEFAULT_MAX_WAIT, now: nil, sleeper: nil)
|
|
18
|
+
@max_wait = max_wait.to_f
|
|
19
|
+
# Wall clock rather than monotonic on purpose: `X-RateLimit-Reset` is an
|
|
20
|
+
# absolute epoch second on Intercom's clock, so the two have to be
|
|
21
|
+
# comparable. `clamp_wait` is what keeps a skewed clock from turning that
|
|
22
|
+
# comparison into a long sleep.
|
|
23
|
+
@now = now || -> { Time.now.to_f }
|
|
24
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@remaining = nil
|
|
27
|
+
@reset_at = nil
|
|
28
|
+
@limit = nil
|
|
29
|
+
@warned_at = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Blocks until the current window has room, then returns. Called once per
|
|
33
|
+
# attempt, retries included: a replayed request spends the budget a first
|
|
34
|
+
# one did.
|
|
35
|
+
def acquire
|
|
36
|
+
wait, declined = @mutex.synchronize { plan_wait }
|
|
37
|
+
|
|
38
|
+
warn_saturated(declined) if declined
|
|
39
|
+
return if wait <= 0
|
|
40
|
+
|
|
41
|
+
@sleeper.call(wait)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# What a response says about the window it was answered in. Called on every
|
|
45
|
+
# response, the 429 included -- that one carries the most useful reset of
|
|
46
|
+
# all.
|
|
47
|
+
def observe(headers)
|
|
48
|
+
limit = integer_header(headers, 'x-ratelimit-limit')
|
|
49
|
+
remaining = integer_header(headers, 'x-ratelimit-remaining')
|
|
50
|
+
reset_at = integer_header(headers, 'x-ratelimit-reset')
|
|
51
|
+
return if remaining.nil? && reset_at.nil?
|
|
52
|
+
|
|
53
|
+
@mutex.synchronize { record(limit, remaining, reset_at) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Decrements locally so in-flight requests do not all pass on the same stale `remaining`; a
|
|
59
|
+
# `remaining` of 1 still allows this request. The declined wait is returned only once per window.
|
|
60
|
+
def plan_wait
|
|
61
|
+
unless exhausted?
|
|
62
|
+
@remaining -= 1 if @remaining
|
|
63
|
+
return [0, nil]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
wait = clamp_wait(@reset_at - @now.call)
|
|
67
|
+
return [0, nil] if wait <= 0
|
|
68
|
+
return [0, first_warning? ? wait : nil] if wait > @max_wait
|
|
69
|
+
|
|
70
|
+
[wait, nil]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Nothing left in a window that has not refilled yet. An unknown state --
|
|
74
|
+
# before the first response -- is not exhaustion: the first request is what
|
|
75
|
+
# discovers the budget.
|
|
76
|
+
def exhausted?
|
|
77
|
+
!@remaining.nil? && @remaining <= 0 && !@reset_at.nil?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Intercom's reset is a timestamp from its clock, and the two clocks can
|
|
81
|
+
# disagree by more than the window is long. A wait longer than a window plus
|
|
82
|
+
# its own slack is that disagreement rather than a window emptying, so it is
|
|
83
|
+
# cut back to something a caller can afford to wait.
|
|
84
|
+
def clamp_wait(seconds)
|
|
85
|
+
return 0.0 if seconds <= 0
|
|
86
|
+
|
|
87
|
+
[seconds, WINDOW + @max_wait].min
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A window is adopted whole. A response answered in an older window than the
|
|
91
|
+
# one already recorded is ignored: replies come back out of order, and one
|
|
92
|
+
# from the previous window would otherwise resurrect a budget already spent.
|
|
93
|
+
#
|
|
94
|
+
# Within the same window the smaller `remaining` wins, so the local
|
|
95
|
+
# decrements of in-flight requests are not undone by a response that left
|
|
96
|
+
# Intercom before they were made.
|
|
97
|
+
def record(limit, remaining, reset_at)
|
|
98
|
+
return if reset_at && @reset_at && reset_at < @reset_at
|
|
99
|
+
|
|
100
|
+
new_window = reset_at && (@reset_at.nil? || reset_at > @reset_at)
|
|
101
|
+
@reset_at = reset_at if reset_at
|
|
102
|
+
@limit = limit if limit
|
|
103
|
+
return if remaining.nil?
|
|
104
|
+
|
|
105
|
+
@remaining = new_window || @remaining.nil? ? remaining : [remaining, @remaining].min
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One line per window. What the warning reports is a saturation that lasts,
|
|
109
|
+
# so a line per request puts one on every request it describes -- hundreds
|
|
110
|
+
# of them, burying the first, which is the only one the operator needed.
|
|
111
|
+
def first_warning?
|
|
112
|
+
now = @now.call
|
|
113
|
+
return false if @warned_at && now - @warned_at < WINDOW
|
|
114
|
+
|
|
115
|
+
@warned_at = now
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def warn_saturated(wait)
|
|
120
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
121
|
+
"[forest_admin_datasource_intercom] the Intercom rate-limit window is spent (limit #{@limit || "unknown"} " \
|
|
122
|
+
"per #{WINDOW.round}s) and its reset is #{wait.round(1)}s out, past the #{@max_wait.round(1)}s this waits. " \
|
|
123
|
+
'Letting the request through -- Intercom may answer 429, which the client retries. A reset this far out ' \
|
|
124
|
+
"usually means this host's clock disagrees with Intercom's. Reported once per #{WINDOW.round}s."
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def integer_header(headers, name)
|
|
129
|
+
value = header_value(headers, name)
|
|
130
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
131
|
+
|
|
132
|
+
Integer(value.to_s.strip, exception: false)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Faraday hands over headers that look themselves up case-insensitively, but
|
|
136
|
+
# what reaches here is whatever the middleware was given -- a plain hash
|
|
137
|
+
# included -- and HTTP header names are case-insensitive on the wire.
|
|
138
|
+
def header_value(headers, name)
|
|
139
|
+
return nil if headers.nil?
|
|
140
|
+
|
|
141
|
+
direct = headers[name]
|
|
142
|
+
return direct unless direct.nil?
|
|
143
|
+
return nil unless headers.respond_to?(:find)
|
|
144
|
+
|
|
145
|
+
headers.find { |key, _value| key.to_s.downcase == name }&.last
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
# Everything governing how the client reacts to a failed request, in one
|
|
3
|
+
# place: which statuses and exceptions are worth another attempt, on which
|
|
4
|
+
# verbs, and how long to wait.
|
|
5
|
+
class RetryPolicy
|
|
6
|
+
# Intercom allocates its quota in 10-second windows, so a 429 is recovered
|
|
7
|
+
# from within one of them -- unlike an API metering by the minute. The cap
|
|
8
|
+
# still has to cover a whole window: faraday-retry gives up outright when
|
|
9
|
+
# Retry-After exceeds max_interval, which would turn the 429 retry into an
|
|
10
|
+
# immediate give-up exactly when it matters.
|
|
11
|
+
DEFAULT_MAX_INTERVAL = 12
|
|
12
|
+
|
|
13
|
+
STATUSES = [429, 500, 502, 503, 504].freeze
|
|
14
|
+
|
|
15
|
+
# Named rather than referenced: the persistent adapter is a gem only loaded
|
|
16
|
+
# when it is the configured adapter -- `adapter: :net_http` never requires
|
|
17
|
+
# it -- so this file cannot name the class outright. Both places that need
|
|
18
|
+
# it resolve it at the moment they are asked, and answer that it cannot have
|
|
19
|
+
# been raised when nothing defined it.
|
|
20
|
+
PERSISTENT_ADAPTER_ERROR = 'Net::HTTP::Persistent::Error'.freeze
|
|
21
|
+
|
|
22
|
+
# faraday-retry's defaults plus ConnectionFailed, and the persistent adapter's own error, which it
|
|
23
|
+
# re-raises unwrapped for messages it does not translate.
|
|
24
|
+
EXCEPTIONS = [
|
|
25
|
+
Errno::ETIMEDOUT, 'Timeout::Error', Faraday::TimeoutError,
|
|
26
|
+
Faraday::RetriableResponse, Faraday::ConnectionFailed,
|
|
27
|
+
PERSISTENT_ADAPTER_ERROR
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# The verbs that change nothing, so any transient failure is worth another
|
|
31
|
+
# attempt. Narrower than faraday-retry's idempotent default: a 502 or a
|
|
32
|
+
# dropped connection on the way back from a POST Intercom did perform would
|
|
33
|
+
# be replayed into a second reply on the conversation, or a second ticket.
|
|
34
|
+
RETRYABLE_METHODS = %i[get head options].freeze
|
|
35
|
+
|
|
36
|
+
# The paths Intercom answers a *read* on through POST: its three search
|
|
37
|
+
# endpoints, and the one listing that paginates by offset. Nothing that
|
|
38
|
+
# writes is spelled this way -- a reply is `POST /conversations/{id}/reply`,
|
|
39
|
+
# a ticket `POST /tickets` -- so the exemption below cannot reach one.
|
|
40
|
+
READ_ONLY_POST_PATHS = %r{/(search|list)\z}
|
|
41
|
+
|
|
42
|
+
# Widens `methods` (ORed by faraday-retry) to a 429 on any verb, and to a dropped connection on a
|
|
43
|
+
# read-only POST -- keep-alive makes an idle socket closed by the server an ordinary failure.
|
|
44
|
+
RETRY_IF = lambda do |env, exception|
|
|
45
|
+
env[:status] == 429 || (RetryPolicy.connection_failure?(exception) &&
|
|
46
|
+
READ_ONLY_POST_PATHS.match?(env[:url]&.path.to_s))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether another attempt would be dialling again rather than re-sending.
|
|
50
|
+
# See `PERSISTENT_ADAPTER_ERROR` for why one of the two is named.
|
|
51
|
+
def self.connection_failure?(exception)
|
|
52
|
+
return true if exception.is_a?(Faraday::ConnectionFailed)
|
|
53
|
+
return false unless Object.const_defined?(PERSISTENT_ADAPTER_ERROR)
|
|
54
|
+
|
|
55
|
+
exception.is_a?(Object.const_get(PERSISTENT_ADAPTER_ERROR))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The cap for a call that must not hold the boot, deliberately below a
|
|
59
|
+
# rate-limit window where DEFAULT_MAX_INTERVAL sits above it: a Retry-After
|
|
60
|
+
# past the cap makes faraday-retry abandon outright, which is what turns a
|
|
61
|
+
# 429 at boot into an immediate give-up rather than a window of waiting per
|
|
62
|
+
# attempt.
|
|
63
|
+
BOOT_MAX_INTERVAL = 2
|
|
64
|
+
|
|
65
|
+
BACKOFF_FACTOR = 2
|
|
66
|
+
|
|
67
|
+
attr_reader :max_retries, :interval, :max_interval
|
|
68
|
+
|
|
69
|
+
# One retry rather than none, for what is read once and never revisited: a
|
|
70
|
+
# transient failure there costs its result for the whole life of the
|
|
71
|
+
# process, and half a second absorbs the hiccup without waiting a 429 out.
|
|
72
|
+
def self.boot
|
|
73
|
+
new(max_retries: 1, interval: 0.5, max_interval: BOOT_MAX_INTERVAL)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def initialize(max_retries: 3, interval: 0.5, max_interval: DEFAULT_MAX_INTERVAL)
|
|
77
|
+
@max_retries = max_retries
|
|
78
|
+
@interval = interval
|
|
79
|
+
@max_interval = max_interval
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_faraday_options
|
|
83
|
+
{
|
|
84
|
+
max: @max_retries,
|
|
85
|
+
interval: @interval,
|
|
86
|
+
max_interval: @max_interval,
|
|
87
|
+
backoff_factor: BACKOFF_FACTOR,
|
|
88
|
+
retry_statuses: STATUSES,
|
|
89
|
+
exceptions: EXCEPTIONS,
|
|
90
|
+
methods: RETRYABLE_METHODS,
|
|
91
|
+
retry_if: RETRY_IF
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Schema
|
|
3
|
+
# What the two introspectors do identically: turn the name a workspace gave
|
|
4
|
+
# an attribute into a name a Forest schema can carry, and say which
|
|
5
|
+
# attribute was left out when two of them land on one column.
|
|
6
|
+
#
|
|
7
|
+
# They differ in where the attributes come from -- `/ticket_types` per
|
|
8
|
+
# ticket type, `/data_attributes` per model -- and in the data types they
|
|
9
|
+
# map, which is why `COLUMN_TYPES` stays with each of them.
|
|
10
|
+
module AttributeNaming
|
|
11
|
+
# What a column name may not contain, and it has nothing to do with
|
|
12
|
+
# Intercom: Forest lists the fields of a request in a **comma-separated**
|
|
13
|
+
# query parameter, and uses a colon to name a field through a relation.
|
|
14
|
+
# A workspace names its attributes in free text -- measured, one is called
|
|
15
|
+
# `ID de l'objet en question (immo, facture, user)` -- and a comma in
|
|
16
|
+
# there splits the projection into fields no collection has, which the
|
|
17
|
+
# agent rejects as a 400 before the page is ever read.
|
|
18
|
+
UNSAFE_IN_A_COLUMN_NAME = /[,:]/
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Intercom hands these back HTML-escaped -- `Ce que j'ai vérifié` --
|
|
23
|
+
# which is an artefact of where they were typed, not part of the name.
|
|
24
|
+
def column_name_for(name)
|
|
25
|
+
CGI.unescapeHTML(name).gsub(UNSAFE_IN_A_COLUMN_NAME, ' ').squeeze(' ').strip
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# An unknown data type reads as a string rather than being dropped:
|
|
29
|
+
# showing the value Intercom sent beats hiding a column because its type
|
|
30
|
+
# is new.
|
|
31
|
+
def column_type_for(definition)
|
|
32
|
+
self.class::COLUMN_TYPES.fetch(definition['data_type'].to_s, self.class::DEFAULT_COLUMN_TYPE)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Two attributes reading as one column: the second is left out rather than
|
|
36
|
+
# sharing the first's entry, which would show its values under the first's
|
|
37
|
+
# name -- wrong values rather than missing ones, and worse.
|
|
38
|
+
#
|
|
39
|
+
# `attribute_kind` is the workspace's own vocabulary for these, which is
|
|
40
|
+
# what the operator has to go and rename.
|
|
41
|
+
def warn_collision(name, kept, column)
|
|
42
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
43
|
+
"[forest_admin_datasource_intercom] the #{attribute_kind} attribute #{name.inspect} is left out: it " \
|
|
44
|
+
"reads as the column #{column.inspect}, which #{kept.inspect} already carries. Rename one of them in " \
|
|
45
|
+
'Intercom to publish both.'
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def attribute_kind = raise(NotImplementedError, "#{self.class} did not implement attribute_kind")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Schema
|
|
3
|
+
# Declared per model, so each column maps to exactly one attribute. Still display-only: which operators
|
|
4
|
+
# Intercom answers on `custom_attributes.{name}` is unmeasured. `api_writable` is carried for lot 4b.
|
|
5
|
+
class DataAttributesIntrospector
|
|
6
|
+
include AttributeNaming
|
|
7
|
+
|
|
8
|
+
# Intercom's attribute data types, mapped onto what Forest can render.
|
|
9
|
+
COLUMN_TYPES = {
|
|
10
|
+
'string' => 'String', 'integer' => 'Number', 'float' => 'Number', 'decimal' => 'Number',
|
|
11
|
+
'boolean' => 'Boolean', 'date' => 'Date', 'datetime' => 'Date'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_COLUMN_TYPE = 'String'.freeze
|
|
15
|
+
|
|
16
|
+
# `name` is the key `custom_attributes` uses, `column_name` the one the
|
|
17
|
+
# schema publishes; they differ whenever the workspace's own name cannot
|
|
18
|
+
# travel through Forest's query string.
|
|
19
|
+
Attribute = Struct.new(:name, :column_name, :column_type, :data_type, :api_writable, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
def initialize(client, model:)
|
|
22
|
+
@client = client
|
|
23
|
+
@model = model
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A token without the permission on this model costs the custom-attribute
|
|
27
|
+
# columns, never the boot of the agent. Any other failure fails the boot:
|
|
28
|
+
# degraded on a timeout or a 5xx, this pod would publish a schema missing
|
|
29
|
+
# columns the next one has, and keep it for the life of the process.
|
|
30
|
+
def attributes
|
|
31
|
+
@attributes ||= build
|
|
32
|
+
rescue APIError => e
|
|
33
|
+
raise unless e.status == 403
|
|
34
|
+
|
|
35
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
36
|
+
"[forest_admin_datasource_intercom] could not read the #{@model} attributes (#{e.message}); " \
|
|
37
|
+
'the collection boots without its custom-attribute columns.'
|
|
38
|
+
)
|
|
39
|
+
@attributes = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build
|
|
45
|
+
# Read on the boot connection: this happens while Rails is starting, and
|
|
46
|
+
# a slow Intercom must not turn that into minutes the operator sits
|
|
47
|
+
# through.
|
|
48
|
+
@client.fetch_all('data_attributes', params: { 'model' => @model }, boot: true)
|
|
49
|
+
.each_with_object({}) { |definition, union| collect(definition, union) }
|
|
50
|
+
.values
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The standard attributes are left out: they are columns this datasource
|
|
54
|
+
# declares by hand, with the filters the search table measured, and
|
|
55
|
+
# publishing them a second time under their `custom_attributes` name would
|
|
56
|
+
# show one fact twice -- the unfilterable copy winning nothing.
|
|
57
|
+
def collect(definition, union)
|
|
58
|
+
return unless definition.is_a?(Hash) && definition['custom'] && !definition['archived']
|
|
59
|
+
|
|
60
|
+
name = definition['name'].to_s
|
|
61
|
+
return if name.empty?
|
|
62
|
+
|
|
63
|
+
column = column_name_for(name)
|
|
64
|
+
return if column.empty?
|
|
65
|
+
|
|
66
|
+
add(union, name, column, definition)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add(union, name, column, definition)
|
|
70
|
+
entry = union[column]
|
|
71
|
+
return union[column] = attribute_from(name, column, definition) if entry.nil?
|
|
72
|
+
return if entry.name == name
|
|
73
|
+
|
|
74
|
+
warn_collision(name, entry.name, column)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def attribute_from(name, column, definition)
|
|
78
|
+
Attribute.new(name: name, column_name: column, column_type: column_type_for(definition),
|
|
79
|
+
data_type: definition['data_type'], api_writable: definition['api_writable'] == true)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# These are declared per model, so that is what the log calls them: it is
|
|
83
|
+
# the workspace's own vocabulary, and where the operator goes to rename
|
|
84
|
+
# the one that was left out.
|
|
85
|
+
def attribute_kind = @model
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Schema
|
|
3
|
+
# Declared per ticket type: one name has a different id on each type (measured: 14162161 vs 14162165) and
|
|
4
|
+
# Intercom filters by id only, so the union columns ship unfilterable (R7). Ids are kept per type for a
|
|
5
|
+
# future per-type filter translation, sparing a second boot-time round trip.
|
|
6
|
+
class TicketAttributesIntrospector
|
|
7
|
+
include AttributeNaming
|
|
8
|
+
|
|
9
|
+
# Intercom's attribute data types, mapped onto what Forest can render. A
|
|
10
|
+
# `list` is a single choice among values the workspace defined, so it reads
|
|
11
|
+
# as a string rather than as a Json blob; `files` is a list of attachments
|
|
12
|
+
# and has no scalar form at all.
|
|
13
|
+
COLUMN_TYPES = {
|
|
14
|
+
'string' => 'String', 'list' => 'String', 'integer' => 'Number', 'decimal' => 'Number',
|
|
15
|
+
'boolean' => 'Boolean', 'datetime' => 'Date', 'date' => 'Date', 'files' => 'Json'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
DEFAULT_COLUMN_TYPE = 'String'.freeze
|
|
19
|
+
|
|
20
|
+
# `name` is the key the payload uses, `column_name` the one the schema
|
|
21
|
+
# publishes; they differ whenever the workspace's own name cannot travel
|
|
22
|
+
# through Forest's query string.
|
|
23
|
+
#
|
|
24
|
+
# `conflicting_ticket_types` names the ticket types declaring the same name
|
|
25
|
+
# with another type: their value would not be what the column holds.
|
|
26
|
+
Attribute = Struct.new(:name, :column_name, :column_type, :data_type, :ids_by_ticket_type,
|
|
27
|
+
:conflicting_ticket_types, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
def initialize(client)
|
|
30
|
+
@client = client
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The union, one entry per attribute name. A token without the
|
|
34
|
+
# ticket-types permission costs the attribute columns, never the boot of
|
|
35
|
+
# the agent; any other failure fails the boot, for the reason
|
|
36
|
+
# `DataAttributesIntrospector#attributes` gives.
|
|
37
|
+
def attributes
|
|
38
|
+
@attributes ||= build
|
|
39
|
+
rescue APIError => e
|
|
40
|
+
raise unless e.status == 403
|
|
41
|
+
|
|
42
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
43
|
+
"[forest_admin_datasource_intercom] could not read the ticket types (#{e.message}); " \
|
|
44
|
+
'the Tickets collection boots without its attribute columns.'
|
|
45
|
+
)
|
|
46
|
+
@attributes = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build
|
|
52
|
+
# Read on the boot connection: this happens while Rails is starting, and
|
|
53
|
+
# a slow Intercom must not turn that into minutes the operator sits
|
|
54
|
+
# through.
|
|
55
|
+
@client.fetch_all('ticket_types', boot: true)
|
|
56
|
+
.each_with_object({}) { |ticket_type, union| collect(ticket_type, union) }
|
|
57
|
+
.values
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def collect(ticket_type, union)
|
|
61
|
+
return unless ticket_type.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
type_id = ticket_type['id'].to_s
|
|
64
|
+
definitions(ticket_type).each do |definition|
|
|
65
|
+
entry = entry_for(definition, union, type_id)
|
|
66
|
+
next if entry.nil?
|
|
67
|
+
|
|
68
|
+
entry.ids_by_ticket_type[type_id] = definition['id'].to_s
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The union is keyed by column name rather than by the workspace's own,
|
|
73
|
+
# since that is what has to be unique in a schema. Two different attributes
|
|
74
|
+
# landing on one column would otherwise share an entry, and the second's
|
|
75
|
+
# values would be read under the first's name -- wrong values rather than
|
|
76
|
+
# missing ones, which is worse.
|
|
77
|
+
def entry_for(definition, union, type_id)
|
|
78
|
+
name = definition['name'].to_s
|
|
79
|
+
# An archived attribute is not offered any more, and a nameless one has
|
|
80
|
+
# nothing to be a column of.
|
|
81
|
+
return nil if name.empty? || definition['archived']
|
|
82
|
+
|
|
83
|
+
column = column_name_for(name)
|
|
84
|
+
return nil if column.empty?
|
|
85
|
+
|
|
86
|
+
entry = union[column]
|
|
87
|
+
return union[column] = attribute_from(name, column, definition) if entry.nil?
|
|
88
|
+
return same_name(entry, definition, type_id) if entry.name == name
|
|
89
|
+
|
|
90
|
+
warn_collision(name, entry.name, column)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The same name declared with another type by another ticket type: that
|
|
95
|
+
# ticket type's value is left out of the column rather than shown under a
|
|
96
|
+
# type it does not have.
|
|
97
|
+
def same_name(entry, definition, type_id)
|
|
98
|
+
column_type = column_type_for(definition)
|
|
99
|
+
return entry if column_type == entry.column_type
|
|
100
|
+
|
|
101
|
+
entry.conflicting_ticket_types << type_id
|
|
102
|
+
warn_type_conflict(entry, column_type, type_id)
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def warn_type_conflict(entry, column_type, type_id)
|
|
107
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
108
|
+
"[forest_admin_datasource_intercom] the ticket attribute #{entry.name.inspect} is a #{column_type} on " \
|
|
109
|
+
"ticket type #{type_id} and a #{entry.column_type} elsewhere; the column stays a #{entry.column_type} " \
|
|
110
|
+
'and reads as empty on that ticket type. Give the two attributes different names to publish both.'
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def attribute_from(name, column, definition)
|
|
115
|
+
Attribute.new(name: name, column_name: column, column_type: column_type_for(definition),
|
|
116
|
+
data_type: definition['data_type'], ids_by_ticket_type: {}, conflicting_ticket_types: [])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def definitions(ticket_type)
|
|
120
|
+
container = ticket_type['ticket_type_attributes']
|
|
121
|
+
return [] unless container.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
list = container['data']
|
|
124
|
+
list.is_a?(Array) ? list.grep(Hash) : []
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# These are declared per ticket type, and that is what the log calls
|
|
128
|
+
# them: the workspace's own vocabulary, and where the operator goes to
|
|
129
|
+
# rename the one that was left out.
|
|
130
|
+
def attribute_kind = 'ticket'
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
# Holds a request until the rate-limit window has room, and feeds the window
|
|
3
|
+
# back what the response says about it. A middleware rather than a call in
|
|
4
|
+
# each client method: there is one code path for every request here, where the
|
|
5
|
+
# client has one per endpoint, and this one also covers the requests the client
|
|
6
|
+
# never issues itself -- the replays `retry` performs.
|
|
7
|
+
class Throttle < Faraday::Middleware
|
|
8
|
+
def initialize(app, limiter:)
|
|
9
|
+
super(app)
|
|
10
|
+
@limiter = limiter
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
@limiter.acquire
|
|
15
|
+
|
|
16
|
+
@app.call(env).on_complete { |response_env| @limiter.observe(response_env[:response_headers]) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require_relative 'forest_admin_datasource_intercom/version'
|
|
2
|
+
require 'cgi/escape'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'logger'
|
|
5
|
+
require 'set'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'uri'
|
|
8
|
+
require 'yaml'
|
|
9
|
+
require 'zeitwerk'
|
|
10
|
+
# Required explicitly rather than relied on via the toolkit: `active_support` brings the autoload
|
|
11
|
+
# `Time.use_zone` needs, `active_support/time` brings `Time.zone` (see `Query::CallerZone`).
|
|
12
|
+
require 'active_support'
|
|
13
|
+
require 'active_support/time'
|
|
14
|
+
require 'faraday'
|
|
15
|
+
require 'faraday/retry'
|
|
16
|
+
require 'forest_admin_datasource_toolkit'
|
|
17
|
+
|
|
18
|
+
loader = Zeitwerk::Loader.for_gem
|
|
19
|
+
loader.setup
|
|
20
|
+
|
|
21
|
+
module ForestAdminDatasourceIntercom
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
class ConfigurationError < Error; end
|
|
24
|
+
|
|
25
|
+
# Descends from the toolkit's ValidationError so the agent answers 400 with the message, not a 500:
|
|
26
|
+
# the datasource refuses a filter Intercom cannot express exactly rather than approximating it.
|
|
27
|
+
class UnsupportedOperatorError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end
|
|
28
|
+
|
|
29
|
+
# Raised when an Intercom API call fails. Carries the HTTP status and the
|
|
30
|
+
# parsed response body so callers can surface Intercom's own error message
|
|
31
|
+
# instead of a generic string.
|
|
32
|
+
class APIError < Error
|
|
33
|
+
attr_reader :status, :body
|
|
34
|
+
|
|
35
|
+
def initialize(message, status: nil, body: nil)
|
|
36
|
+
super(message)
|
|
37
|
+
@status = status
|
|
38
|
+
@body = body
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
attr_writer :logger
|
|
44
|
+
|
|
45
|
+
def logger
|
|
46
|
+
@logger ||= default_logger
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def default_logger
|
|
52
|
+
return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
53
|
+
|
|
54
|
+
Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_intercom' }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|