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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/README.md +179 -0
- data/Rakefile +6 -0
- data/forest_admin_datasource_pylon.gemspec +36 -0
- data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
- data/lib/forest_admin_datasource_pylon/client.rb +436 -0
- data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
- data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
- data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
- data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
- data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
- data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
- data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
- data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
- data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
- data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
- data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
- data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
- data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
- data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
- data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
- data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
- data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
- data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
- data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
- data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
- data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
- data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
- data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
- data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
- data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
- data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
- data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
- data/lib/forest_admin_datasource_pylon/version.rb +3 -0
- data/lib/forest_admin_datasource_pylon.rb +70 -0
- 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
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,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
|