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,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