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,225 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Query
|
|
3
|
+
# Loads `search_fields.yml`, edited by hand from what `forest_admin_intercom_probe` measures. Every row
|
|
4
|
+
# is validated on load, so a bad edit fails at boot rather than producing an unexplainable schema.
|
|
5
|
+
module SearchFields # rubocop:disable Metrics/ModuleLength
|
|
6
|
+
PATH = File.expand_path('search_fields.yml', __dir__)
|
|
7
|
+
|
|
8
|
+
# The operators Intercom's search DSL spells, and nothing else: `=`, `!=`,
|
|
9
|
+
# `>`, `<`, `>=`, `<=`, the substring pair `~` / `!~`, the anchors `^` /
|
|
10
|
+
# `$`, and the membership pair. Which of them an endpoint honours on a
|
|
11
|
+
# given field is the table's business; this is only the alphabet.
|
|
12
|
+
KNOWN_OPERATORS = ['=', '!=', '>', '<', '>=', '<=', '~', '!~', '^', '$', 'IN', 'NIN'].freeze
|
|
13
|
+
# Read off the operator table rather than listed again here: a type with
|
|
14
|
+
# no spelling of its own would pass this validation and raise when the
|
|
15
|
+
# schema asked what to publish on it.
|
|
16
|
+
KNOWN_TYPES = OperatorTable.types
|
|
17
|
+
KNOWN_SOURCES = %w[measured spec].freeze
|
|
18
|
+
|
|
19
|
+
# `source: spec` marks a row the probe has not confirmed. Only `/contacts/search` honours `sortable`; the
|
|
20
|
+
# other endpoints silently ignore a sort, so absent means unsortable. `value_map` (`values` in the file)
|
|
21
|
+
# maps a shown value onto the searched one, e.g. a ticket shows `Customer` but searches `request`.
|
|
22
|
+
Field = Struct.new(:column, :field, :type, :operators, :source, :sortable, :value_map, keyword_init: true) do
|
|
23
|
+
def measured? = source == 'measured'
|
|
24
|
+
def sortable? = sortable == true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A column that stays unfilterable, and why. The reason travels into the
|
|
28
|
+
# refusal the operator reads, so it names what to filter on instead
|
|
29
|
+
# wherever there is something to name.
|
|
30
|
+
Refusal = Struct.new(:column, :reason, :source, keyword_init: true) do
|
|
31
|
+
def measured? = source == 'measured'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Endpoint = Struct.new(:name, :path, :measured_at, :fields, :refused, :candidates, :attribute_refusal,
|
|
35
|
+
keyword_init: true) do
|
|
36
|
+
# Whether the probe has run against a real workspace for this endpoint.
|
|
37
|
+
# False means every `spec` row is still a candidate.
|
|
38
|
+
def measured? = !measured_at.nil?
|
|
39
|
+
|
|
40
|
+
def field(column) = fields[column]
|
|
41
|
+
def refusal(column) = refused[column]
|
|
42
|
+
def filterable_columns = fields.keys
|
|
43
|
+
def sortable_columns = fields.values.select(&:sortable?).map(&:column)
|
|
44
|
+
def unmeasured_fields = fields.values.reject(&:measured?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Long by line count only: it is one parse method and one validation per
|
|
48
|
+
# thing the file can get wrong, and a validation that does not say what
|
|
49
|
+
# is wrong is one nobody can act on.
|
|
50
|
+
class << self # rubocop:disable Metrics/ClassLength
|
|
51
|
+
def fetch(name)
|
|
52
|
+
table[name.to_s] ||
|
|
53
|
+
raise(ConfigurationError, "Unknown Intercom search endpoint #{name.inspect}; " \
|
|
54
|
+
"the table declares #{table.keys.join(", ")}.")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def endpoints = table.keys
|
|
58
|
+
|
|
59
|
+
def table
|
|
60
|
+
@table ||= build(YAML.safe_load_file(PATH))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Public so a spec can feed it a table of its own: this validation is the
|
|
64
|
+
# reason the file can be rewritten by a script without the package
|
|
65
|
+
# trusting whatever comes back.
|
|
66
|
+
def build(raw)
|
|
67
|
+
raw.fetch('endpoints').to_h { |name, definition| [name, endpoint(name, definition)] }.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def endpoint(name, definition)
|
|
73
|
+
filterable = fields(name, definition['fields'])
|
|
74
|
+
refused = refusals(name, definition['refused'])
|
|
75
|
+
validate_no_overlap!(name, filterable, refused)
|
|
76
|
+
|
|
77
|
+
Endpoint.new(
|
|
78
|
+
name: name,
|
|
79
|
+
path: definition.fetch('path'),
|
|
80
|
+
measured_at: definition['measured_at'],
|
|
81
|
+
fields: filterable,
|
|
82
|
+
refused: refused,
|
|
83
|
+
candidates: Array(definition['candidates']).freeze,
|
|
84
|
+
attribute_refusal: attribute_refusal(name, definition)
|
|
85
|
+
).freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# One refusal for the workspace-defined attribute columns, whose names are unknown until boot:
|
|
89
|
+
# `ticket_attributes` (per ticket type) or `custom_attributes` (per model).
|
|
90
|
+
def attribute_refusal(endpoint, definition)
|
|
91
|
+
column = %w[ticket_attributes custom_attributes].find { |key| definition.key?(key) }
|
|
92
|
+
return nil if column.nil?
|
|
93
|
+
|
|
94
|
+
row = definition.fetch(column)
|
|
95
|
+
refusal = Refusal.new(column: column, reason: squish(row.fetch('reason')), source: row.fetch('source'))
|
|
96
|
+
validate_source!(endpoint, column, refusal)
|
|
97
|
+
validate_attributes_unfilterable!(endpoint, column, row)
|
|
98
|
+
|
|
99
|
+
refusal.freeze
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# `filterable: true` is a state nothing here implements: the whole
|
|
103
|
+
# block is a refusal, and an edit flipping the flag would publish
|
|
104
|
+
# nothing new while making the file say the opposite of what it does.
|
|
105
|
+
def validate_attributes_unfilterable!(endpoint, column, row)
|
|
106
|
+
return if row.fetch('filterable') == false
|
|
107
|
+
|
|
108
|
+
malformed!(endpoint, column,
|
|
109
|
+
"filterable #{row["filterable"].inspect} is not something this reads; the attribute columns " \
|
|
110
|
+
'are published for display only, and the block exists to say why')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A column filed as both filterable and refused. `Endpoint#field` is
|
|
114
|
+
# consulted first, so the filterable row would win and the refusal --
|
|
115
|
+
# with the reason an operator reads -- would be ignored in silence. A
|
|
116
|
+
# measurement merged by hand next to the row it should have replaced
|
|
117
|
+
# takes this shape, and it fails at load rather than producing a schema
|
|
118
|
+
# nobody can explain.
|
|
119
|
+
def validate_no_overlap!(endpoint, filterable, refused)
|
|
120
|
+
both = filterable.keys & refused.keys
|
|
121
|
+
return if both.empty?
|
|
122
|
+
|
|
123
|
+
malformed!(endpoint, both.join(', '),
|
|
124
|
+
'it is declared filterable and refused at once; the refusal would be ignored')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fields(endpoint, declared)
|
|
128
|
+
(declared || {}).to_h do |column, row|
|
|
129
|
+
field = Field.new(column: column, field: row.fetch('field'), type: row.fetch('type'),
|
|
130
|
+
operators: Array(row['operators']).freeze, source: row.fetch('source'),
|
|
131
|
+
sortable: row.fetch('sortable', false), value_map: row['values']).freeze
|
|
132
|
+
validate_field!(endpoint, field)
|
|
133
|
+
|
|
134
|
+
[column, field]
|
|
135
|
+
end.freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def refusals(endpoint, declared)
|
|
139
|
+
(declared || {}).to_h do |column, row|
|
|
140
|
+
refusal = Refusal.new(column: column, reason: squish(row.fetch('reason')),
|
|
141
|
+
source: row.fetch('source')).freeze
|
|
142
|
+
validate_source!(endpoint, column, refusal)
|
|
143
|
+
|
|
144
|
+
[column, refusal]
|
|
145
|
+
end.freeze
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_field!(endpoint, field)
|
|
149
|
+
validate_source!(endpoint, field.column, field)
|
|
150
|
+
validate_type!(endpoint, field)
|
|
151
|
+
validate_operators!(endpoint, field)
|
|
152
|
+
validate_publishable!(endpoint, field)
|
|
153
|
+
validate_sortable!(endpoint, field)
|
|
154
|
+
validate_values!(endpoint, field)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Operators the DSL spells, none of which this column's *type* can carry:
|
|
158
|
+
# a `date` row declaring `~` passes the alphabet above and publishes
|
|
159
|
+
# nothing, `OperatorTable` mapping no Forest operator onto it. The result
|
|
160
|
+
# is a column the table calls filterable that no filter can reach, and
|
|
161
|
+
# the translator refusing every request on it -- the same
|
|
162
|
+
# advertise-then-refuse this package exists to prevent, one layer lower.
|
|
163
|
+
def validate_publishable!(endpoint, field)
|
|
164
|
+
return unless OperatorTable.forest_operators(field).empty?
|
|
165
|
+
|
|
166
|
+
malformed!(endpoint, field.column,
|
|
167
|
+
"none of #{field.operators.join(", ")} is an operator Intercom answers on a " \
|
|
168
|
+
"#{field.type}, so the column would publish no filter at all")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Anything but a boolean, `"true"` above all: YAML reads it as a string,
|
|
172
|
+
# which is truthy in Ruby and would publish a sortable column out of a
|
|
173
|
+
# typo the file cannot otherwise show.
|
|
174
|
+
def validate_sortable!(endpoint, field)
|
|
175
|
+
return if [true, false].include?(field.sortable)
|
|
176
|
+
|
|
177
|
+
malformed!(endpoint, field.column, "sortable #{field.sortable.inspect} is neither true nor false")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def validate_values!(endpoint, field)
|
|
181
|
+
map = field.value_map
|
|
182
|
+
return if map.nil? || (map.is_a?(Hash) && !map.empty? && map.to_a.flatten.all?(String))
|
|
183
|
+
|
|
184
|
+
malformed!(endpoint, field.column, "values #{map.inspect} is not a map of strings to strings")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def validate_type!(endpoint, field)
|
|
188
|
+
return if KNOWN_TYPES.include?(field.type)
|
|
189
|
+
|
|
190
|
+
malformed!(endpoint, field.column, "type #{field.type.inspect} is not one of #{KNOWN_TYPES.join(", ")}")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# An empty operator list is how a table stops short of saying anything:
|
|
194
|
+
# it would publish a filterable column no operator can reach. A column
|
|
195
|
+
# Intercom does not filter belongs in the refused table, where it comes
|
|
196
|
+
# with the reason an operator reads.
|
|
197
|
+
def validate_operators!(endpoint, field)
|
|
198
|
+
if field.operators.empty?
|
|
199
|
+
malformed!(endpoint, field.column,
|
|
200
|
+
'it declares no operator; a column Intercom cannot filter belongs in the refused table')
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
unknown = field.operators - KNOWN_OPERATORS
|
|
204
|
+
return if unknown.empty?
|
|
205
|
+
|
|
206
|
+
malformed!(endpoint, field.column, "Intercom's search DSL has no operator #{unknown.join(", ")}")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_source!(endpoint, column, row)
|
|
210
|
+
return if KNOWN_SOURCES.include?(row.source)
|
|
211
|
+
|
|
212
|
+
malformed!(endpoint, column, "source #{row.source.inspect} is neither #{KNOWN_SOURCES.join(" nor ")}")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def malformed!(endpoint, column, detail)
|
|
216
|
+
raise ConfigurationError, "#{File.basename(PATH)} is malformed at #{endpoint}.#{column}: #{detail}."
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# A YAML folded block keeps the newlines the file needs to stay readable;
|
|
220
|
+
# the reason travels into a one-line message.
|
|
221
|
+
def squish(text) = text.to_s.split.join(' ')
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|