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,143 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # Read through POST /tickets/search: Intercom has no GET /tickets, and records come under `tickets`.
4
+ # Every response carries each ticket's whole timeline with no field selection (measured: 155 parts on one
5
+ # ticket), hence the small page size and the free part-derived columns.
6
+ class Ticket < CursorCollection
7
+ include ContactIdentity
8
+ include Ticket::Serializer
9
+ include Ticket::DerivedColumns
10
+ include CustomAttributes
11
+ # The same thread a conversation publishes, and free here: the parts are
12
+ # in the response whether or not anything asks for them.
13
+ include Timeline
14
+
15
+ # Intercom accepts 150. This is not that: it is what keeps one page of
16
+ # tickets, timelines included, a response an agent can hold and an operator
17
+ # can wait for. Provisional until measured against real response sizes on
18
+ # the customer's workspace.
19
+ MAX_TICKETS_PER_PAGE = 25
20
+
21
+ # `/tickets/search` demands a query, so a list view sends the least noisy
22
+ # predicate that matches everything. Every ticket has a creation date, and
23
+ # a bound at the epoch keeps whatever the day-granular truncation does to
24
+ # it harmless.
25
+ MATCH_EVERY_TICKET = { 'field' => 'created_at', 'operator' => '>', 'value' => '0' }.freeze
26
+
27
+ def initialize(datasource, attributes: [])
28
+ @attributes = attributes
29
+ super(datasource, 'IntercomTicket')
30
+ # Declared so the search reaches `refuse_search!` instead of being
31
+ # emulated by the customizer as an equality on every String column,
32
+ # which `/tickets/search` answers with an empty page.
33
+ enable_search
34
+ end
35
+
36
+ protected
37
+
38
+ def record_endpoint = 'tickets'
39
+ def list_key = 'tickets'
40
+ def searchable = 'tickets'
41
+ def max_page_size = MAX_TICKETS_PER_PAGE
42
+
43
+ # The part bodies are HTML written by end customers, and rendering
44
+ # third-party HTML inside Forest is neither safe nor useful (R10). Sent on
45
+ # the search, where Intercom does not document it: a parameter it ignores
46
+ # costs a query string, while the one it honours saves every row of the
47
+ # thread from coming back as markup.
48
+ def read_params = { 'display_as' => 'plaintext' }
49
+
50
+ # Intercom exposes no `GET /tickets`, so a list view searches too: with the
51
+ # filter it was given, or with the predicate that matches everything when
52
+ # it was given none.
53
+ def searchable_only? = true
54
+ def match_all_query = MATCH_EVERY_TICKET
55
+
56
+ def enrich(records, rows, projection)
57
+ embed_contact_identity(records, rows, projection)
58
+ embed_derived_columns(records, rows, projection)
59
+ embed_timeline(records, rows, projection)
60
+ end
61
+
62
+ private
63
+
64
+ def define_schema
65
+ add_column('id', 'String', is_primary_key: true)
66
+ # The number the support team says out loud, next to the id the API
67
+ # answers by.
68
+ add_column('ticket_id', 'String')
69
+ # Read as the label (`Customer` / `Back-office` / `Tracker`); a filter sends
70
+ # the word `/tickets/search` takes for it (`request` / `task` / `tracker`).
71
+ add_column('category', 'String')
72
+ add_column('open', 'Boolean')
73
+ add_column('is_shared', 'Boolean')
74
+ add_column('created_at', 'Date')
75
+ add_column('updated_at', 'Date')
76
+ add_column('admin_assignee_id', 'String')
77
+ add_column('team_assignee_id', 'String')
78
+ # The ticket carries its company as an id alone, unlike a conversation
79
+ # which carries the whole object: the account name would cost a request
80
+ # per row, so it is not offered here. Measured: the id is Intercom's own,
81
+ # not the customer's external one, which is what a relation will have to
82
+ # target in lot 4.
83
+ add_column('company_id', 'String')
84
+ define_state_columns
85
+ define_type_columns
86
+ define_contact_columns
87
+ define_derived_columns
88
+ add_column('part_count', 'Number')
89
+ add_column('timeline', 'Json')
90
+ # Before the attribute columns rather than after: a workspace attribute
91
+ # whose name lands on a relation is then skipped with a warning, the way
92
+ # one landing on a column already is. Declared after, it would collide
93
+ # and take the boot with it.
94
+ define_relations
95
+ register_attribute_columns
96
+ end
97
+
98
+ # The state arrives embedded as a whole object, so its label costs nothing:
99
+ # a queue reads without a join. One label and not three -- the category and
100
+ # the customer-facing label are read through the `state` relation, which is
101
+ # where every field of a state lives. Neither of the two was ever
102
+ # filterable, so nothing that could be saved in a segment or a scope
103
+ # depended on them.
104
+ def define_state_columns
105
+ add_column('state_id', 'String')
106
+ add_column('state_label', 'String')
107
+ add_column('previous_state_id', 'String')
108
+ end
109
+
110
+ def define_type_columns
111
+ add_column('ticket_type_id', 'String')
112
+ add_column('ticket_type_name', 'String')
113
+ end
114
+
115
+ # /tickets/search filters on admin_assignee_id, team_assignee_id and ticket_type_id, on no state id, and
116
+ # `contact_ids` is unconfirmed; a traversal the endpoint cannot filter is refused by name, not dropped.
117
+ def define_relations
118
+ add_many_to_one('admin_assignee', foreign_collection: 'IntercomAdmin', foreign_key: 'admin_assignee_id')
119
+ add_many_to_one('team_assignee', foreign_collection: 'IntercomTeam', foreign_key: 'team_assignee_id')
120
+ add_many_to_one('state', foreign_collection: 'IntercomTicketState', foreign_key: 'state_id')
121
+ add_many_to_one('previous_state', foreign_collection: 'IntercomTicketState',
122
+ foreign_key: 'previous_state_id')
123
+ add_many_to_one('ticket_type', foreign_collection: 'IntercomTicketType', foreign_key: 'ticket_type_id')
124
+ add_many_to_one('contact', foreign_collection: 'IntercomContact', foreign_key: 'contact_id')
125
+ end
126
+
127
+ # Free: the parts come with the search response regardless. An empty list is an empty thread; a payload
128
+ # with no parts leaves it nil (unknown), as a listed conversation does.
129
+ def embed_timeline(records, rows, projection)
130
+ return unless column_asked?(projection, 'timeline')
131
+
132
+ records.each_with_index { |record, index| rows[index]['timeline'] = parts_of(record) && build_timeline(record) }
133
+ truncated = records.count { |record| truncated?(record) }
134
+ warn_truncated_timelines(truncated) if truncated.positive?
135
+ end
136
+
137
+ # The attribute columns of every ticket type, in union. Read at boot by
138
+ # `TicketAttributesIntrospector`, which is also where a workspace's own
139
+ # name is turned into one a Forest query string can carry.
140
+ def attribute_kind = 'ticket'
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,40 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # The ticket states of the workspace. A ticket carries its state as an id, so
4
+ # without this collection a support queue reads as a column of numbers.
5
+ class TicketState < FetchAllCollection
6
+ def initialize(datasource)
7
+ super(datasource, 'IntercomTicketState')
8
+ end
9
+
10
+ protected
11
+
12
+ def fetch_all
13
+ client.fetch_all('ticket_states')
14
+ end
15
+
16
+ # Two labels rather than one: `internal_label` is what the support team
17
+ # sees, `external_label` what the customer is shown. An operator reading a
18
+ # queue needs the first, and needs to know what the second says.
19
+ def serialize(ticket_state)
20
+ attrs = ticket_state.is_a?(Hash) ? ticket_state : {}
21
+
22
+ { 'id' => stringify_id(attrs['id']),
23
+ 'category' => attrs['category'],
24
+ 'internal_label' => attrs['internal_label'],
25
+ 'external_label' => attrs['external_label'],
26
+ 'archived' => attrs['archived'] }
27
+ end
28
+
29
+ private
30
+
31
+ def define_schema
32
+ add_column('id', 'String', is_primary_key: true)
33
+ add_column('category', 'String')
34
+ add_column('internal_label', 'String')
35
+ add_column('external_label', 'String')
36
+ add_column('archived', 'Boolean')
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,46 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # The ticket types the workspace defines. They are what makes a ticket's type
4
+ # readable, and they are also where the ticket attributes are declared --
5
+ # which is what the ticket collection reads them for.
6
+ class TicketType < FetchAllCollection
7
+ def initialize(datasource)
8
+ super(datasource, 'IntercomTicketType')
9
+ end
10
+
11
+ protected
12
+
13
+ def fetch_all
14
+ client.fetch_all('ticket_types')
15
+ end
16
+
17
+ # `ticket_type_attributes` is deliberately left out: it is a nested list of
18
+ # attribute definitions, useful to the ticket collection and meaningless as
19
+ # a column. An attribute of the same name carries a different id from one
20
+ # type to the next (measured), which is exactly why the ticket collection
21
+ # has to read the definitions rather than assume them.
22
+ def serialize(ticket_type)
23
+ attrs = ticket_type.is_a?(Hash) ? ticket_type : {}
24
+
25
+ { 'id' => stringify_id(attrs['id']),
26
+ 'name' => attrs['name'],
27
+ 'description' => attrs['description'],
28
+ 'category' => attrs['category'],
29
+ 'icon' => attrs['icon'],
30
+ 'archived' => attrs['archived'] }
31
+ end
32
+
33
+ private
34
+
35
+ def define_schema
36
+ add_column('id', 'String', is_primary_key: true)
37
+ add_column('name', 'String')
38
+ add_column('description', 'String')
39
+ # The label (`Customer` / `Back-office` / `Tracker`), as on a ticket.
40
+ add_column('category', 'String')
41
+ add_column('icon', 'String')
42
+ add_column('archived', 'Boolean')
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,40 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # The thread of a conversation or ticket. `part_type` is kept, and internal notes are included next to
4
+ # customer replies -- that is what a thread is on Intercom. Intercom keeps only the 500 most recent parts.
5
+ module Timeline
6
+ private
7
+
8
+ def build_timeline(entity)
9
+ attrs = entity.is_a?(Hash) ? entity : {}
10
+
11
+ [opening_entry(attrs)].compact + (parts_of(attrs) || []).map { |part| part_entry(part) }
12
+ end
13
+
14
+ # What comes before the parts. A conversation opens on its `source`, which
15
+ # is not a part at all; a ticket opens on its first part like any other
16
+ # event, so there is nothing to prepend.
17
+ def opening_entry(_attrs) = nil
18
+
19
+ def part_entry(part)
20
+ attrs = part.is_a?(Hash) ? part : {}
21
+
22
+ entry(part_type: attrs['part_type'], created_at: attrs['created_at'], author: attrs['author'],
23
+ body: attrs['body'], attachments: attrs['attachments'])
24
+ .merge('id' => stringify_id(attrs['id']), 'redacted' => attrs['redacted'])
25
+ end
26
+
27
+ def entry(part_type:, created_at:, author:, body:, attachments:)
28
+ writer = author.is_a?(Hash) ? author : {}
29
+
30
+ { 'part_type' => part_type,
31
+ 'created_at' => stamp(created_at),
32
+ 'author_type' => writer['type'],
33
+ 'author_name' => writer['name'],
34
+ 'author_email' => writer['email'],
35
+ 'body' => body,
36
+ 'attachment_count' => Array(attachments).size }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,226 @@
1
+ module ForestAdminDatasourceIntercom
2
+ # Long by line count only: every option of the datasource is declared here
3
+ # with the reason it has the default it has, and next to the check that
4
+ # refuses the shapes it cannot mean. Splitting the checks off would put an
5
+ # option and its meaning in two files.
6
+ class Configuration # rubocop:disable Metrics/ClassLength
7
+ # A workspace is hosted in one region and answers in that region only. The
8
+ # host is therefore a configuration parameter rather than a constant:
9
+ # `api.intercom.io` does route to the right region, but a customer under
10
+ # GDPR wants its requests to reach the European host and nothing else.
11
+ REGION_HOSTS = {
12
+ us: 'https://api.intercom.io',
13
+ eu: 'https://api.eu.intercom.io',
14
+ au: 'https://api.au.intercom.io'
15
+ }.freeze
16
+
17
+ DEFAULT_REGION = :us
18
+
19
+ # Where a cleartext base_url reaches nobody else's network, so the bearer
20
+ # header crossing it in clear is not worth a word.
21
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1] 0.0.0.0].freeze
22
+
23
+ # Without an explicit version a request follows the workspace's own default,
24
+ # which an operator can change on Intercom's side -- and the payloads change
25
+ # shape under us. Pinned to what the spike ran against; 2.14 and 2.16 both
26
+ # answered, and the response echoes the version back, so `Client#me`
27
+ # verifies at boot that the pin was honoured.
28
+ DEFAULT_API_VERSION = '2.16'.freeze
29
+
30
+ # Reference lists are re-read on every page otherwise (four sequential round trips per ticket list);
31
+ # a minute bounds staleness. `0` disables the store. Records are never cached -- see `Cache`.
32
+ DEFAULT_REFERENCE_CACHE_TTL = 60
33
+
34
+ # A ticket list is ~7 requests to one host, so keep-alive saves a TLS handshake each. The pool is sized
35
+ # against the server's thread count (Puma default 5): a thread waiting >0.5s for a connection gets a
36
+ # non-retried timeout blamed on Intercom. `adapter: :net_http` opts out; an unloadable adapter is refused.
37
+ DEFAULT_ADAPTER = [:net_http_persistent, { pool_size: 25 }].freeze
38
+
39
+ # How many records a read by id fetches at once -- a related list of
40
+ # companies, a page of timelines -- Intercom answering those one request
41
+ # each. One by default: every extra read in flight holds a connection of
42
+ # the pool above, so widening this is the integrator's call, made against
43
+ # `pool_size` and the server's thread count together.
44
+ DEFAULT_CONCURRENT_READS = 1
45
+
46
+ attr_reader :access_token, :region, :base_url, :api_version, :open_timeout, :timeout,
47
+ :retry_policy, :rate_limiter, :boot_open_timeout, :boot_timeout, :boot_retry_policy,
48
+ :reference_cache_ttl, :adapter, :reference_cache, :concurrent_reads
49
+
50
+ # `rate_limiter: nil` leaves the 429 retry as the only rate-limit handling. The `boot_` trio governs
51
+ # reads made while the datasource is constructed (attribute introspection), during the Rails boot.
52
+ def initialize(access_token:, region: nil, base_url: nil, api_version: DEFAULT_API_VERSION,
53
+ open_timeout: 5, timeout: 30, retry_policy: RetryPolicy.new,
54
+ rate_limiter: RateLimiter.new, boot_open_timeout: 3, boot_timeout: 10,
55
+ boot_retry_policy: RetryPolicy.boot,
56
+ reference_cache_ttl: DEFAULT_REFERENCE_CACHE_TTL, adapter: nil,
57
+ concurrent_reads: DEFAULT_CONCURRENT_READS)
58
+ @access_token = access_token
59
+ @region = (region || DEFAULT_REGION).to_s.downcase.to_sym
60
+ @base_url = base_url
61
+ @api_version = api_version.to_s
62
+ @open_timeout = open_timeout
63
+ @timeout = timeout
64
+ @retry_policy = retry_policy
65
+ @rate_limiter = rate_limiter
66
+ @boot_open_timeout = boot_open_timeout
67
+ @boot_timeout = boot_timeout
68
+ @boot_retry_policy = boot_retry_policy
69
+ @reference_cache_ttl = reference_cache_ttl
70
+ @adapter = normalize_adapter(adapter)
71
+ @concurrent_reads = concurrent_reads
72
+ validate!
73
+ # One store per Configuration, hence per token, like the rate limiter:
74
+ # what it holds is a workspace's own lists, and two workspaces do not
75
+ # share them. Built here rather than lazily -- a memoized reader would
76
+ # hand two stores to the two threads that first raced for it, and one of
77
+ # them would then write into a store nothing else reads.
78
+ @reference_cache = Cache.new(ttl: @reference_cache_ttl)
79
+ end
80
+
81
+ # An explicit `base_url` wins over the region: it is what points the client
82
+ # at a mock server or an egress proxy, neither of which is a region.
83
+ def url
84
+ @url ||= (@base_url || REGION_HOSTS.fetch(@region)).chomp('/')
85
+ end
86
+
87
+ # The url with any credentials in it masked. `URI` accepts them --
88
+ # `https://user:pass@proxy/...` -- and the refusal that names such a url
89
+ # would otherwise print a second secret exactly where this class keeps the
90
+ # first one from going. A query string is masked whole for the same
91
+ # reason: it is where a gateway takes its key. Read by `Client#inspect`
92
+ # too, which prints the same url.
93
+ def redacted_url
94
+ @redacted_url ||= url.sub(%r{\A([a-zA-Z][\w+.-]*://)[^/@]*@}, '\1[FILTERED]@')
95
+ .sub(/\?.*\z/m, '?[FILTERED]')
96
+ end
97
+
98
+ # `access_token` is a bearer credential, and nothing prints a Configuration
99
+ # on purpose: what reaches an `inspect` is a Rails error page, or a
100
+ # `logger.debug` of something holding one. The default would put the token
101
+ # in clear there. `Client` and `Datasource` mask their own for the same
102
+ # reason -- together they cut every path from an object this package hands
103
+ # out to the credential.
104
+ def inspect
105
+ "#<#{self.class.name} url=#{redacted_url.inspect} api_version=#{@api_version.inspect} " \
106
+ 'access_token=[FILTERED]>'
107
+ end
108
+
109
+ private
110
+
111
+ def validate!
112
+ raise ConfigurationError, 'ForestAdminDatasourceIntercom missing required config: access_token' if
113
+ blank?(@access_token)
114
+
115
+ validate_region!
116
+ validate_base_url!
117
+ raise ConfigurationError, 'ForestAdminDatasourceIntercom api_version cannot be empty' if blank?(@api_version)
118
+
119
+ validate_reference_cache_ttl!
120
+ validate_concurrent_reads!
121
+ end
122
+
123
+ def validate_concurrent_reads!
124
+ return if @concurrent_reads.is_a?(Integer) && @concurrent_reads.positive?
125
+
126
+ raise ConfigurationError,
127
+ 'ForestAdminDatasourceIntercom concurrent_reads must be a whole number of reads, one or more, got ' \
128
+ "#{@concurrent_reads.inspect}"
129
+ end
130
+
131
+ # Refuses nil, non-numeric strings and Infinity rather than coercing them into a silent 0 (cache off)
132
+ # or a never-expiring cache.
133
+ def validate_reference_cache_ttl!
134
+ return if @reference_cache_ttl.is_a?(Numeric) && @reference_cache_ttl.finite? &&
135
+ !@reference_cache_ttl.negative?
136
+
137
+ raise ConfigurationError,
138
+ 'ForestAdminDatasourceIntercom reference_cache_ttl must be a finite number of seconds, zero or ' \
139
+ "more, got #{@reference_cache_ttl.inspect}"
140
+ end
141
+
142
+ # Faraday's adapter registry is keyed by symbol: a string name (as YAML hands over) silently lost
143
+ # keep-alive. Whether the name resolves is left to the client, which refuses one this process cannot load.
144
+ def normalize_adapter(adapter)
145
+ return nil if adapter.nil?
146
+
147
+ name, options = adapter.is_a?(Array) ? adapter : [adapter, nil]
148
+ refuse_adapter!(adapter) unless name.respond_to?(:to_sym)
149
+ refuse_adapter!(adapter) unless options.nil? || namable_hash?(options)
150
+
151
+ options.nil? ? name.to_sym : [name.to_sym, options.to_h { |key, value| [key.to_sym, value] }]
152
+ end
153
+
154
+ def namable_hash?(options)
155
+ options.is_a?(Hash) && options.keys.all? { |key| key.respond_to?(:to_sym) }
156
+ end
157
+
158
+ def refuse_adapter!(adapter)
159
+ raise ConfigurationError,
160
+ 'ForestAdminDatasourceIntercom adapter must be an adapter name or a [name, options] pair, got ' \
161
+ "#{adapter.inspect}"
162
+ end
163
+
164
+ def validate_region!
165
+ return if @base_url || REGION_HOSTS.key?(@region)
166
+
167
+ raise ConfigurationError,
168
+ "ForestAdminDatasourceIntercom unknown region #{@region.inspect}: " \
169
+ "expected one of #{REGION_HOSTS.keys.map(&:inspect).join(", ")}, or an explicit base_url."
170
+ end
171
+
172
+ # A base url that is not absolute makes Faraday resolve every path against
173
+ # the process's working directory instead of Intercom, which surfaces much
174
+ # later as a connection failure naming nothing.
175
+ def validate_base_url!
176
+ return if @base_url.nil?
177
+
178
+ uri = URI.parse(@base_url)
179
+ refuse_credentials! if uri.userinfo
180
+ return warn_cleartext!(uri) if uri.is_a?(URI::HTTP) && !blank?(uri.host)
181
+
182
+ raise ConfigurationError,
183
+ "ForestAdminDatasourceIntercom base_url must be an absolute http(s) url, got #{redacted_url.inspect}"
184
+ rescue URI::InvalidURIError
185
+ # Without the parser's error, whose message quotes the url unredacted.
186
+ raise ConfigurationError,
187
+ "ForestAdminDatasourceIntercom base_url is not a valid url: #{redacted_url.inspect}", cause: nil
188
+ end
189
+
190
+ # Faraday turns credentials in the url into a Basic `Authorization`
191
+ # header, which the Intercom bearer token then replaces: they would be
192
+ # dropped on every request without a word. An egress proxy takes its
193
+ # credentials from `HTTPS_PROXY`, which Faraday sends as
194
+ # `Proxy-Authorization` instead.
195
+ def refuse_credentials!
196
+ raise ConfigurationError,
197
+ "ForestAdminDatasourceIntercom base_url #{redacted_url.inspect} carries credentials, which cannot " \
198
+ 'travel: the Authorization header carries the Intercom access token. For a credentialed egress ' \
199
+ 'proxy, set HTTPS_PROXY=https://user:password@proxy:port instead.'
200
+ end
201
+
202
+ # `URI::HTTPS` is a `URI::HTTP`, so plain http passes above -- and it is
203
+ # worth passing: a mock server in a test suite is one, and that is half of
204
+ # what `base_url` is for. What it costs is `Authorization: Bearer <token>`
205
+ # travelling in clear to whatever sits at the other end, and that token
206
+ # reads the whole workspace. Named rather than allowed in silence; a
207
+ # loopback host is nobody else's network, so it says nothing there.
208
+ def warn_cleartext!(uri)
209
+ return if uri.scheme == 'https' || loopback?(uri.host)
210
+
211
+ ForestAdminDatasourceIntercom.logger.warn(
212
+ "[forest_admin_datasource_intercom] base_url #{redacted_url.inspect} is not https, and every request " \
213
+ 'carries the Intercom access token as a bearer header. Anything on the path can read it and use it ' \
214
+ 'against the workspace. Use https, or terminate TLS before the network this crosses.'
215
+ )
216
+ end
217
+
218
+ def loopback?(host)
219
+ LOOPBACK_HOSTS.include?(host.to_s.downcase) || host.to_s.downcase.end_with?('.localhost')
220
+ end
221
+
222
+ def blank?(value)
223
+ value.nil? || value.to_s.strip.empty?
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,78 @@
1
+ module ForestAdminDatasourceIntercom
2
+ class Datasource < ForestAdminDatasourceToolkit::Datasource
3
+ attr_reader :client, :configuration
4
+
5
+ def initialize(access_token:, **options)
6
+ super()
7
+ @configuration = Configuration.new(access_token: access_token, **options)
8
+ @client = Client.new(@configuration)
9
+
10
+ verify_api_version
11
+ register_collections
12
+ end
13
+
14
+ # The datasource is what a Rails error page or a `logger.debug` is likeliest
15
+ # to print, and it holds the client whose connections carry the access token.
16
+ # Every collection will reach that token the same way, through the
17
+ # `@datasource` the toolkit's Collection keeps, so cutting the chain here
18
+ # covers them too -- and spares the recursive dump the default `inspect`
19
+ # walks into, a datasource and its collections pointing at each other.
20
+ def inspect
21
+ "#<#{self.class.name} collections=#{collections.keys.inspect}>"
22
+ end
23
+
24
+ private
25
+
26
+ # The reference collections first: they are what turns an assignee id into a
27
+ # teammate and a state id into a label. No request is made here -- each
28
+ # collection reads its endpoint when it is listed, so a datasource boots
29
+ # whatever Intercom is doing, and a workspace the token cannot read costs
30
+ # rows rather than the agent.
31
+ def register_collections
32
+ add_collection(Collections::Admin.new(self))
33
+ add_collection(Collections::Team.new(self))
34
+ # The join Intercom does not expose: without it the membership of a team is
35
+ # an array of ids on either side, since a many-to-many needs a collection
36
+ # to travel through.
37
+ add_collection(Collections::TeamMembership.new(self))
38
+ add_collection(Collections::TicketType.new(self))
39
+ add_collection(Collections::TicketState.new(self))
40
+ # Contacts and Companies before the collections that point at them, so a
41
+ # relation is declared next to a target the datasource already holds.
42
+ # Each carries the custom attributes its workspace defines, read at boot:
43
+ # they cannot be discovered from a payload, a contact carrying the values
44
+ # of the attributes it happens to have been given.
45
+ add_collection(Collections::Contact.new(self, attributes: model_attributes('contact')))
46
+ add_collection(Collections::Company.new(self, attributes: model_attributes('company')))
47
+ add_collection(Collections::Conversation.new(self))
48
+ # The attributes a workspace defines on its ticket types, which are
49
+ # columns of the Tickets collection and cannot be discovered from a ticket
50
+ # payload either -- a ticket carries the values of its own type only.
51
+ add_collection(Collections::Ticket.new(self, attributes: ticket_attributes))
52
+ end
53
+
54
+ # Intercom silently serves the workspace's default API version when the pin is not honoured;
55
+ # `Client#me` checks the echoed version. A token that cannot reach `/me` costs the check, not the boot.
56
+ def verify_api_version
57
+ @client.me(boot: true)
58
+ rescue APIError => e
59
+ ForestAdminDatasourceIntercom.logger.warn(
60
+ "[forest_admin_datasource_intercom] could not read /me at boot (HTTP #{e.status || "-"}); the API " \
61
+ 'version Intercom serves was not checked against the pinned one, and a workspace serving another one ' \
62
+ 'answers payloads of another shape.'
63
+ )
64
+ end
65
+
66
+ # The three attribute reads of the boot, alongside the version check above.
67
+ # Each degrades to no attribute column rather than to a failed boot: a
68
+ # token missing a permission costs the columns it could not read, never
69
+ # the agent.
70
+ def ticket_attributes
71
+ Schema::TicketAttributesIntrospector.new(@client).attributes
72
+ end
73
+
74
+ def model_attributes(model)
75
+ Schema::DataAttributesIntrospector.new(@client, model: model).attributes
76
+ end
77
+ end
78
+ end