forest_admin_datasource_pylon 1.41.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/README.md +179 -0
  4. data/Rakefile +6 -0
  5. data/forest_admin_datasource_pylon.gemspec +36 -0
  6. data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
  7. data/lib/forest_admin_datasource_pylon/client.rb +436 -0
  8. data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
  9. data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
  10. data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
  11. data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
  12. data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
  13. data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
  14. data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
  15. data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
  16. data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
  17. data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
  18. data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
  19. data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
  20. data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
  21. data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
  22. data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
  23. data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
  24. data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
  25. data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
  26. data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
  27. data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
  28. data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
  29. data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
  30. data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
  31. data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
  32. data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
  33. data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
  34. data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
  35. data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
  36. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
  37. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
  38. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
  39. data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
  40. data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
  41. data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
  42. data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
  43. data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
  44. data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
  45. data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
  46. data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
  47. data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
  48. data/lib/forest_admin_datasource_pylon/version.rb +3 -0
  49. data/lib/forest_admin_datasource_pylon.rb +70 -0
  50. metadata +152 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f400c44e13350edf1d12e584a23df0baf4441fefac43bf549e6247c5528d7faf
4
+ data.tar.gz: d6e26553966604b9f146a0dd90c2b660c153e70438569829ea452bc6fd1a7f46
5
+ SHA512:
6
+ metadata.gz: 96156637324e2961eec8c4b67ffdd1d6ae2c4f35287e26ba77e70a1933118336f8e54e14523f11547129703ca26b82cd4b03a7fda8f4cd3432ec9e925c86e3e2
7
+ data.tar.gz: 0d7e86625aa670bbec771b87a8e101aa8db884aaea650990221bf3bce0a2a215687aca441efa23b6a8c788445cca01905adbfd6ec98ed918f21537a8005ff96d
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # Forest — Pylon datasource
2
+
3
+ Surface [Pylon](https://usepylon.com) issues, accounts, contacts, users and teams as Forest
4
+ collections, with filters, free-text search, relations, the conversation thread, custom fields,
5
+ CRUD writes and two action plugins.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem 'forest_admin_datasource_pylon'
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```ruby
17
+ # app/lib/forest_admin_rails/create_agent.rb
18
+ ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(
19
+ ForestAdminDatasourcePylon::Datasource.new(api_key: ENV['PYLON_API_KEY'])
20
+ )
21
+ ```
22
+
23
+ A single Bearer token is the whole configuration. `GET /me` is the health check: it returns the
24
+ organization owning the token, which is enough to prove the credentials are usable.
25
+
26
+ ## Collections
27
+
28
+ | Collection | Pylon resource | Read | Write |
29
+ | --- | --- | --- | --- |
30
+ | `PylonIssue` | `/issues` | `POST /issues/search`, cursor-paginated | create / update / delete |
31
+ | `PylonAccount` | `/accounts` | search + list, cursor-paginated | create / update / delete |
32
+ | `PylonContact` | `/contacts` | search + list, cursor-paginated | create / update / delete |
33
+ | `PylonUser` | `/users` | whole dataset, unpaginated | update |
34
+ | `PylonTeam` | `/teams` | whole dataset, unpaginated | create / update |
35
+
36
+ `PylonIssue`, `PylonAccount` and `PylonContact` also carry their Pylon custom fields, introspected
37
+ at boot from `GET /custom-fields`.
38
+
39
+ ## What the API cannot do, and what this does about it
40
+
41
+ Pylon is a ticketing API, not a database, and several things Forest asks for have no equivalent.
42
+ Where that happens this datasource **refuses with a message naming the reason** rather than
43
+ answering something that looks right and is not. All of these arrive as a 400 carrying the text.
44
+
45
+ - **No aggregate endpoint and no total.** `aggregate` is refused on the cursor-backed collections:
46
+ counting the pages a walk collected would answer a fraction of a collection as if it were the
47
+ whole of it, so those are not advertised as countable. `PylonUser` and `PylonTeam` are the
48
+ exception — their endpoint hands back every record, so a count or a group over it is the figure
49
+ a server-side aggregation would have given. That claim is checked rather than assumed: the read
50
+ follows a cursor if Pylon ever advertises one, and refuses outright rather than answer over a
51
+ fraction of the collection should it paginate past what one walk covers.
52
+ - **No sort parameter on `/issues/search`.** Issues always come back newest first. A requested
53
+ order is reported in the log rather than silently swallowed.
54
+ - **No `id` filter on the search endpoints.** A primary-key lookup is short-circuited to
55
+ `GET /issues/{id}`, one request per id. That fan-out is capped per page (`MAX_ID_LOOKUPS`), and
56
+ a selection naming more ids *and* filtering them further is refused, since which records the
57
+ page holds could only be known by reading all of them.
58
+ - **No joins.** A condition on a relation is answered by reading the foreign collection for its
59
+ keys and sending them as an `in`. Past `MAX_RELATION_KEYS` the condition is refused.
60
+ - **Search and id lookup are different endpoints.** Combining a free-text search with a filter on
61
+ `id` is refused: neither endpoint can do the other's half.
62
+ - **Writes are one record per request.** A write reaching more records than one pass covers is
63
+ refused up front; one that fails halfway reports exactly which records were written, so a retry
64
+ can target the untouched ones rather than performing the write twice.
65
+ - **Not every column can be written.** A read-only column sent alongside a real edit is dropped
66
+ and the edit performed. An edit naming *only* such columns is refused, naming them: it would
67
+ write nothing, and the record the route reads back would show the operator their change
68
+ reverting with no reason given.
69
+
70
+ ## Rate limits
71
+
72
+ Pylon meters **per endpoint**, not per token, from 30 to 300 requests a minute depending on the
73
+ endpoint. `RateLimits` holds the documented budget of every endpoint this client calls, and
74
+ `RateLimiter` spaces requests out so each one is spent rather than exceeded — a sliding window per
75
+ endpoint, in front of the 429 retry rather than instead of it.
76
+
77
+ The limiter is a smoother, not a guarantee: past `DEFAULT_MAX_WAIT` a request goes out anyway and
78
+ the 429 retry takes over, with one log line per endpoint per window saying so. Under real
79
+ saturation — several agents or processes on the same token — the retry is the defence.
80
+
81
+ The retry is bounded, deliberately. A 429 carries a `Retry-After` of up to a full minute, and
82
+ waiting one out on every attempt held the calling thread for minutes on a request the Forest
83
+ server had already timed out. `RetryPolicy::DEFAULT_MAX_INTERVAL` caps what one attempt waits;
84
+ past it the 429 surfaces as an error instead. So a saturated endpoint answers the operator with a
85
+ message rather than with a page that arrives after they gave up. Raise it — or lower
86
+ `max_retries` — to trade the other way:
87
+
88
+ ```ruby
89
+ ForestAdminDatasourcePylon::Datasource.new(
90
+ api_key: ENV['PYLON_API_KEY'],
91
+ retry_policy: ForestAdminDatasourcePylon::RetryPolicy.new(max_retries: 1, max_interval: 65)
92
+ )
93
+ ```
94
+
95
+ To meter on your own side instead, pass `rate_limiter: nil`:
96
+
97
+ ```ruby
98
+ ForestAdminDatasourcePylon::Datasource.new(api_key: ENV['PYLON_API_KEY'], rate_limiter: nil)
99
+ ```
100
+
101
+ ## Boot-time introspection
102
+
103
+ Custom fields are read while the datasource is being constructed, one call per object type. That
104
+ sits in front of a Rails boot, so it runs on its own connection with short timeouts and a single
105
+ quick retry (`boot_open_timeout`, `boot_timeout`, `boot_retry_policy`), and the first failure
106
+ stops the remaining object types from being tried.
107
+
108
+ An introspection that fails costs the custom columns, not the datasource: the agent boots on the
109
+ native schema and says so in the log.
110
+
111
+ ## Action plugins
112
+
113
+ ### `CloseIssue`
114
+
115
+ Moves the selected issues to a state, single and bulk, one request per issue with a per-id rescue
116
+ so one failure does not abort the batch.
117
+
118
+ ```ruby
119
+ @agent.collection :Ticket do |collection|
120
+ collection.use(ForestAdminDatasourcePylon::Plugins::CloseIssue, {
121
+ datasource: pylon_datasource, # required
122
+ issue_id_field: 'pylon_issue_id', # omit when the action sits on PylonIssue itself
123
+ state: 'closed', # default
124
+ scopes: %i[single bulk] # default
125
+ })
126
+ end
127
+ ```
128
+
129
+ Two things to know before granting it.
130
+
131
+ **Which scope bounds it.** The state is written straight through the Pylon client rather than
132
+ through `PylonIssue`, because the action is registered on the host collection — but the ids were
133
+ read before that write, through the collection the action sits on, and the agent intersects the
134
+ operator's scope into the filter that read them. So the scope that bounds this is the scope of the
135
+ **host** collection: mounted on `PylonIssue`, a scope or segment on `PylonIssue` bounds exactly what
136
+ it closes; mounted on a business collection with `issue_id_field`, what bounds it is the records the
137
+ operator may see there, and the issue ids those records carry. In that second form the column is
138
+ the authority — an operator who can write it can name any Pylon issue — so treat it as one.
139
+
140
+ **The batch is capped at `MAX_TARGETS` (20) issues**, the budget a filter-driven write already gets:
141
+ Pylon takes one request per issue, so a wider selection is a long run of sequential writes the
142
+ request may time out on, leaving the issues closed up to that point closed and reporting which ones
143
+ to nobody. Past the cap the run is refused before its first write, the way `update` and `delete`
144
+ refuse a selection costing more than `MAX_WRITE_REQUESTS`. The cap counts the issues named, not the
145
+ records selected: a column of issue ids is not a key, so a hundred host records naming ten issues is
146
+ a batch of ten.
147
+
148
+ ### `CreateIssueWithNotification`
149
+
150
+ Creates an issue from a form and notifies the requester, optionally writing the new issue id back
151
+ onto the host record.
152
+
153
+ ```ruby
154
+ @agent.collection :Customer do |collection|
155
+ collection.use(ForestAdminDatasourcePylon::Plugins::CreateIssueWithNotification, {
156
+ datasource: pylon_datasource, # required
157
+ sender_email: 'support@example.com', # required when the destination is email
158
+ issue_id_field: 'pylon_issue_id', # optional id writeback
159
+ email_templates: [{ title: 'Outage', content: '<p>Sorry {{ record.name }}</p>' }]
160
+ })
161
+ end
162
+ ```
163
+
164
+ `{{ record.field }}` tokens are interpolated from the host record, HTML-escaped in the message
165
+ body. Template titles must be unique and cannot be `"No template"`, which names the option that
166
+ picks none of them.
167
+
168
+ The message body is HTML the operator writes and Pylon delivers to the requester, and the
169
+ requester address is a free-text field. Restrict both actions to the roles that should be able to
170
+ send mail on your organization's behalf.
171
+
172
+ ## Development
173
+
174
+ ```bash
175
+ cd packages/forest_admin_datasource_pylon
176
+ bundle install
177
+ bundle exec rspec
178
+ bundle exec rubocop
179
+ ```
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,36 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
3
+
4
+ require_relative 'lib/forest_admin_datasource_pylon/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'forest_admin_datasource_pylon'
8
+ spec.version = ForestAdminDatasourcePylon::VERSION
9
+ spec.authors = ['Forest Admin']
10
+ spec.email = ['contact@forestadmin.com']
11
+ spec.homepage = 'https://www.forestadmin.com'
12
+ spec.summary = 'Pylon datasource for Forest Admin Ruby agent.'
13
+ spec.description = 'Surface Pylon issues, accounts, contacts, users and teams as Forest Admin collections.'
14
+ spec.license = 'GPL-3.0'
15
+ spec.required_ruby_version = '>= 3.0.0'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby'
19
+ spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md'
20
+ spec.metadata['rubygems_mfa_required'] = 'false'
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (File.expand_path(f) == __FILE__) ||
25
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
26
+ end
27
+ end
28
+ spec.bindir = 'exe'
29
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ['lib']
31
+
32
+ spec.add_dependency 'activesupport', '>= 6.1'
33
+ spec.add_dependency 'faraday', '~> 2.0'
34
+ spec.add_dependency 'faraday-retry', '~> 2.0'
35
+ spec.add_dependency 'zeitwerk', '~> 2.3'
36
+ end
@@ -0,0 +1,88 @@
1
+ module ForestAdminDatasourcePylon
2
+ class Client
3
+ # The write half of the client: one explicit method per Pylon write
4
+ # endpoint, each delegating to the shared helpers below.
5
+ #
6
+ # Nothing here degrades. `best_effort` exists for the calls whose result
7
+ # enriches a page — a thread that could not be read costs a column — where a
8
+ # write that silently did nothing would tell the operator their edit landed.
9
+ #
10
+ # Pylon exposes no POST or DELETE on users, and no DELETE on teams. The
11
+ # collections answer those, not the client, which only spells the endpoints
12
+ # that exist.
13
+ module Writes
14
+ # `title` and `body_html` are the two fields POST /issues requires.
15
+ def create_issue(attributes) = post_resource('issues', attributes)
16
+ def update_issue(id, attributes) = patch_resource('issues', id, attributes)
17
+ def delete_issue(id) = delete_resource('issues', id)
18
+
19
+ def create_account(attributes) = post_resource('accounts', attributes)
20
+ def update_account(id, attributes) = patch_resource('accounts', id, attributes)
21
+ def delete_account(id) = delete_resource('accounts', id)
22
+
23
+ def create_contact(attributes) = post_resource('contacts', attributes)
24
+ def update_contact(id, attributes) = patch_resource('contacts', id, attributes)
25
+ def delete_contact(id) = delete_resource('contacts', id)
26
+
27
+ def create_team(attributes) = post_resource('teams', attributes)
28
+ def update_team(id, attributes) = patch_resource('teams', id, attributes)
29
+
30
+ def update_user(id, attributes) = patch_resource('users', id, attributes)
31
+
32
+ private
33
+
34
+ def post_resource(resource, attributes)
35
+ operation = "create(#{resource})"
36
+
37
+ must_succeed(operation) { extract_written(connection.post(resource, attributes).body, operation) }
38
+ end
39
+
40
+ # The id comes from the record the operator acted on, so it is escaped
41
+ # before being joined to the path, like every read does.
42
+ def patch_resource(resource, id, attributes)
43
+ path = "#{resource}/#{Faraday::Utils.escape(id)}"
44
+ operation = "update(#{path})"
45
+
46
+ must_succeed(operation) { extract_updated(connection.patch(path, attributes).body, operation) }
47
+ end
48
+
49
+ # Answers true rather than the body: Pylon returns 200 or 204 with nothing
50
+ # worth reading, and a caller has no record left to serialize.
51
+ def delete_resource(resource, id)
52
+ path = "#{resource}/#{Faraday::Utils.escape(id)}"
53
+
54
+ must_succeed("delete(#{path})") do
55
+ connection.delete(path)
56
+ true
57
+ end
58
+ end
59
+
60
+ # Pylon answers a write with the written record under `data`. Anything else
61
+ # broke the contract: `extract_data` hands the body back untouched when
62
+ # `data` is absent, which is what a read wants and a write must not accept
63
+ # — the collection would serialize the envelope into a record with no id.
64
+ def extract_written(body, operation)
65
+ record = body['data'] if body.is_a?(Hash)
66
+ return record if record.is_a?(Hash)
67
+
68
+ refuse_body_shape(body, operation, "missing 'data'")
69
+ end
70
+
71
+ # An update discards its record, so a 204, an empty body or a null `data`
72
+ # is the write having landed with nothing to hand back: raising there would
73
+ # report a failure on a record Pylon already patched, and abort the records
74
+ # a bulk edit had left to write.
75
+ def extract_updated(body, operation)
76
+ record = body['data'] if body.is_a?(Hash)
77
+ return record if record.nil? || record.is_a?(Hash)
78
+
79
+ refuse_body_shape(body, operation, "'data' is not a record")
80
+ end
81
+
82
+ def refuse_body_shape(body, operation, detail)
83
+ raise APIError,
84
+ "Pylon API #{operation} returned an unexpected body shape (#{detail}): #{body.inspect}"
85
+ end
86
+ end
87
+ end
88
+ end