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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
# Everything governing how the client reacts to a failed request, in one
|
|
3
|
+
# place: which statuses and exceptions are worth another attempt, on which
|
|
4
|
+
# verbs, and how long to wait.
|
|
5
|
+
class RetryPolicy
|
|
6
|
+
# What one attempt may be held back before the retry is abandoned instead.
|
|
7
|
+
# faraday-retry gives up outright when Retry-After exceeds max_interval
|
|
8
|
+
# (`return if retry_after > max_interval`), so this is also the bound on what
|
|
9
|
+
# a 429 costs: past it the client raises at once rather than sleeping.
|
|
10
|
+
#
|
|
11
|
+
# A fifth of a Pylon window rather than the whole of it, on purpose. Pylon
|
|
12
|
+
# quotas are per-minute, so a 429 carries a Retry-After of up to 60s, and
|
|
13
|
+
# honouring it three times over parked the calling thread for three minutes
|
|
14
|
+
# -- long after the Forest server timed out the request nobody will now read,
|
|
15
|
+
# and multiplied by the 20 sequential calls a single list view may spend
|
|
16
|
+
# (`MAX_ID_LOOKUPS`). The figure is what keeps the sleeps of one whole
|
|
17
|
+
# request under a minute: `max_retries` waits of this, plus the
|
|
18
|
+
# `RateLimiter::DEFAULT_MAX_WAIT` each of the four attempts may also pay.
|
|
19
|
+
#
|
|
20
|
+
# The trade, plainly: a Retry-After past this makes the 429 surface as an
|
|
21
|
+
# error where it used to be waited out, which is most of them -- the value
|
|
22
|
+
# being the remainder of the minute window. Absorbing them is `RateLimiter`'s
|
|
23
|
+
# job, metering each endpoint inside its budget before Pylon has to answer
|
|
24
|
+
# 429 at all; this retry is the backstop for the traffic the limiter cannot
|
|
25
|
+
# see, and a backstop may cost less than what it protects.
|
|
26
|
+
DEFAULT_MAX_INTERVAL = 12
|
|
27
|
+
|
|
28
|
+
STATUSES = [429, 502, 503, 504].freeze
|
|
29
|
+
|
|
30
|
+
# faraday-retry's defaults plus ConnectionFailed: a dropped connection is
|
|
31
|
+
# exactly the transient failure a resilient client should absorb, and it is
|
|
32
|
+
# not retried out of the box.
|
|
33
|
+
EXCEPTIONS = [
|
|
34
|
+
Errno::ETIMEDOUT, 'Timeout::Error', Faraday::TimeoutError,
|
|
35
|
+
Faraday::RetriableResponse, Faraday::ConnectionFailed
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# The verbs that change nothing, so any transient failure is worth another
|
|
39
|
+
# attempt. Narrower than faraday-retry's idempotent default: a 502 or a
|
|
40
|
+
# dropped connection on the way back from a DELETE Pylon did perform is
|
|
41
|
+
# replayed into a 404, which the write path then surfaces as a deletion that
|
|
42
|
+
# failed when it landed.
|
|
43
|
+
#
|
|
44
|
+
# A 429 stays safe to retry on any verb, Pylon having rejected the request
|
|
45
|
+
# before processing it, and travels through retry_if rather than through this
|
|
46
|
+
# list: faraday-retry ORs the two, so methods can only widen the set, never
|
|
47
|
+
# restrict it.
|
|
48
|
+
RETRYABLE_METHODS = %i[get head options].freeze
|
|
49
|
+
RETRY_IF = ->(env, _exception) { env[:status] == 429 }
|
|
50
|
+
|
|
51
|
+
# The same mechanism as DEFAULT_MAX_INTERVAL, tightened for a call that must
|
|
52
|
+
# not hold the boot: what a request may wait once the agent is serving is
|
|
53
|
+
# more than an operator watching Rails come up should pay per object type.
|
|
54
|
+
BOOT_MAX_INTERVAL = 2
|
|
55
|
+
|
|
56
|
+
BACKOFF_FACTOR = 2
|
|
57
|
+
|
|
58
|
+
attr_reader :max_retries, :interval, :max_interval
|
|
59
|
+
|
|
60
|
+
# One retry rather than none, for what is read once and never revisited: a
|
|
61
|
+
# transient failure there costs its result for the whole life of the process,
|
|
62
|
+
# and half a second absorbs the hiccup without waiting a 429 out.
|
|
63
|
+
def self.boot
|
|
64
|
+
new(max_retries: 1, interval: 0.5, max_interval: BOOT_MAX_INTERVAL)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(max_retries: 3, interval: 0.5, max_interval: DEFAULT_MAX_INTERVAL)
|
|
68
|
+
@max_retries = max_retries
|
|
69
|
+
@interval = interval
|
|
70
|
+
@max_interval = max_interval
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_faraday_options
|
|
74
|
+
{
|
|
75
|
+
max: @max_retries,
|
|
76
|
+
interval: @interval,
|
|
77
|
+
max_interval: @max_interval,
|
|
78
|
+
backoff_factor: BACKOFF_FACTOR,
|
|
79
|
+
retry_statuses: STATUSES,
|
|
80
|
+
exceptions: EXCEPTIONS,
|
|
81
|
+
methods: RETRYABLE_METHODS,
|
|
82
|
+
retry_if: RETRY_IF
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Schema
|
|
3
|
+
# Turns the custom fields an organization defined in Pylon into columns, as
|
|
4
|
+
# entries shaped `{ column_name:, schema:, multi_value: }` — what
|
|
5
|
+
# `add_custom_fields` registers on a collection, and what the payload
|
|
6
|
+
# builder writes a value back through.
|
|
7
|
+
#
|
|
8
|
+
# `column_name` is the Pylon slug verbatim, and there is no second key
|
|
9
|
+
# carrying it: the slug is both what a read payload indexes the values by and
|
|
10
|
+
# what a search filter sends as `field`, so renaming the column would add a
|
|
11
|
+
# mapping to keep in step for nothing.
|
|
12
|
+
#
|
|
13
|
+
# Pylon defines custom fields per object type, and asks for that type on
|
|
14
|
+
# every call: `issue`, `account` and `contact` are the three this datasource
|
|
15
|
+
# has a collection for — it also exposes `task`, `project`, `meeting` and
|
|
16
|
+
# `opportunity`, while users and teams carry no custom field at all.
|
|
17
|
+
class CustomFieldsIntrospector
|
|
18
|
+
ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema
|
|
19
|
+
Maps = Query::OperatorMaps
|
|
20
|
+
|
|
21
|
+
# A type absent from this table is skipped rather than guessed at: a column
|
|
22
|
+
# whose Forest type does not match what Pylon holds would filter and
|
|
23
|
+
# display wrong, which is worse than not being there.
|
|
24
|
+
#
|
|
25
|
+
# `user` holds a Pylon user id, kept a String rather than turned into a
|
|
26
|
+
# relation to PylonUser: a custom field is registered after the relations
|
|
27
|
+
# are declared, and a foreign key the operator can read is what this story
|
|
28
|
+
# promises.
|
|
29
|
+
PYLON_TO_COLUMN_TYPE = {
|
|
30
|
+
'text' => 'String',
|
|
31
|
+
'url' => 'String',
|
|
32
|
+
'user' => 'String',
|
|
33
|
+
'number' => 'Number',
|
|
34
|
+
'decimal' => 'Number',
|
|
35
|
+
'boolean' => 'Boolean',
|
|
36
|
+
'date' => 'Dateonly',
|
|
37
|
+
'datetime' => 'Date',
|
|
38
|
+
'select' => 'Enum',
|
|
39
|
+
'multiselect' => 'Json'
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# The types Pylon writes back through `values` rather than `value`.
|
|
43
|
+
MULTI_VALUE_TYPES = %w[multiselect].freeze
|
|
44
|
+
|
|
45
|
+
BASE_OPS = (Maps::EQUALITY.keys + Maps::PRESENCE.keys).freeze
|
|
46
|
+
|
|
47
|
+
# A date drops the membership operators on the way, `Rules` granting a DATE
|
|
48
|
+
# or a DATEONLY column no array operator -- the hazard already documented
|
|
49
|
+
# for MEMBERSHIP in `operator_maps.rb`. Native date columns declare the
|
|
50
|
+
# comparisons alone for the same reason.
|
|
51
|
+
#
|
|
52
|
+
# Dropping them is not what keeps `in` out of the UI, though, and nothing
|
|
53
|
+
# here can: `OperatorsEquivalenceCollectionDecorator` republishes it from
|
|
54
|
+
# the `equal` this set declares, the IN transform depending on EQUAL for
|
|
55
|
+
# every column type. A DATEONLY also gets `after_x_hours_ago` /
|
|
56
|
+
# `before_x_hours_ago` republished from the comparisons, `Times.compare`
|
|
57
|
+
# deriving them for that type where `Rules` refuses them. The validator
|
|
58
|
+
# then rejects all three, so the operator is offered a date filter the
|
|
59
|
+
# agent answers with a 400. The contradiction is the toolkit's to settle
|
|
60
|
+
# and is tracked as PRD-989; the set below is what Pylon accepts, which is
|
|
61
|
+
# the only question this table can answer.
|
|
62
|
+
TIME_OPS = (BASE_OPS - Maps::MEMBERSHIP.keys + Maps::TIME.keys).freeze
|
|
63
|
+
|
|
64
|
+
# Drawn from `CUSTOM_FIELD_OPS`, the set every search endpoint accepts on a
|
|
65
|
+
# custom field, so a collection's clamp has nothing to drop.
|
|
66
|
+
#
|
|
67
|
+
# A Number gets no comparison: Pylon documents `time_is_after` /
|
|
68
|
+
# `time_is_before` for the bare comparisons and nothing else, so a numeric
|
|
69
|
+
# range would travel as a time filter. A multiselect gets nothing at all —
|
|
70
|
+
# its membership operators are not part of what a custom field accepts.
|
|
71
|
+
OPERATORS = {
|
|
72
|
+
'String' => (BASE_OPS + Maps::FULL_TEXT.keys).freeze,
|
|
73
|
+
'Enum' => BASE_OPS,
|
|
74
|
+
'Number' => BASE_OPS,
|
|
75
|
+
'Boolean' => BASE_OPS,
|
|
76
|
+
'Date' => TIME_OPS,
|
|
77
|
+
'Dateonly' => TIME_OPS,
|
|
78
|
+
'Json' => [].freeze
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
def initialize(client)
|
|
82
|
+
@client = client
|
|
83
|
+
@unreachable = false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def issue_custom_fields = introspect('issue')
|
|
87
|
+
def account_custom_fields = introspect('account')
|
|
88
|
+
def contact_custom_fields = introspect('contact')
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# One object type per call, three of them, all of it in front of a Rails
|
|
93
|
+
# boot the operator sits through. The first failure stands for the rest:
|
|
94
|
+
# a Pylon that is down, or a token missing the permission, fails the two
|
|
95
|
+
# that follow the same way, and each is bounded per request rather than
|
|
96
|
+
# across the three — so trying them anyway spends the bound three times to
|
|
97
|
+
# learn what the first one already said.
|
|
98
|
+
def introspect(object_type)
|
|
99
|
+
return [] if @unreachable
|
|
100
|
+
|
|
101
|
+
definitions = @client.fetch_custom_fields(object_type)
|
|
102
|
+
return give_up(object_type) if definitions.nil?
|
|
103
|
+
|
|
104
|
+
definitions.filter_map { |raw| build_entry(raw, object_type) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def give_up(object_type)
|
|
108
|
+
@unreachable = true
|
|
109
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
110
|
+
"[forest_admin_datasource_pylon] Custom fields could not be read for #{object_type}; " \
|
|
111
|
+
'the object types after it are left unread rather than held against the same failure. ' \
|
|
112
|
+
'The datasource boots on the native schema, without the custom columns.'
|
|
113
|
+
)
|
|
114
|
+
[]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_entry(raw, object_type)
|
|
118
|
+
return nil unless raw.is_a?(Hash)
|
|
119
|
+
|
|
120
|
+
slug = raw['slug'].to_s
|
|
121
|
+
return nil if slug.empty?
|
|
122
|
+
|
|
123
|
+
column_type = PYLON_TO_COLUMN_TYPE[raw['type']]
|
|
124
|
+
return warn_unknown_type(raw, slug, object_type) if column_type.nil?
|
|
125
|
+
|
|
126
|
+
{ column_name: slug, schema: build_schema(raw, column_type),
|
|
127
|
+
multi_value: MULTI_VALUE_TYPES.include?(raw['type']) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A custom field is writable when Pylon says it is: it flags the ones
|
|
131
|
+
# synced from an app or an integration, which its own endpoints refuse.
|
|
132
|
+
# Nothing is sortable -- no Pylon endpoint takes a sort parameter -- and
|
|
133
|
+
# nothing is groupable: one column left groupable turns `supportGroups` on
|
|
134
|
+
# for the whole collection, and the group-by the UI then offers errors.
|
|
135
|
+
def build_schema(raw, column_type)
|
|
136
|
+
opts = { column_type: column_type,
|
|
137
|
+
filter_operators: OPERATORS.fetch(column_type, []),
|
|
138
|
+
is_read_only: !writable_definition?(raw),
|
|
139
|
+
is_sortable: false,
|
|
140
|
+
is_groupable: false }
|
|
141
|
+
|
|
142
|
+
column_type == 'Enum' ? enum_schema(raw, opts) : ColumnSchema.new(**opts)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Pylon reads a select back — and filters it — as the slug of the option,
|
|
146
|
+
# never as its label, so those are the values the column advertises.
|
|
147
|
+
#
|
|
148
|
+
# Forest refuses an Enum carrying no value: a select whose options were all
|
|
149
|
+
# removed falls back to String, so the column still shows what it holds.
|
|
150
|
+
#
|
|
151
|
+
# Read-only there whatever Pylon says of the field, unlike every other
|
|
152
|
+
# fallback here: a select is written as the slug of one of its options, so
|
|
153
|
+
# a free-text editor on one offers the operator no value the endpoint would
|
|
154
|
+
# accept. Showing what it holds is the whole of what this can do.
|
|
155
|
+
def enum_schema(raw, opts)
|
|
156
|
+
values = option_slugs(raw)
|
|
157
|
+
return ColumnSchema.new(**opts, enum_values: values) unless values.empty?
|
|
158
|
+
|
|
159
|
+
ColumnSchema.new(**opts, column_type: 'String',
|
|
160
|
+
filter_operators: OPERATORS.fetch('String'), is_read_only: true)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def option_slugs(raw)
|
|
164
|
+
metadata = raw['select_metadata']
|
|
165
|
+
options = metadata.is_a?(Hash) ? metadata['options'] : nil
|
|
166
|
+
|
|
167
|
+
Array(options).filter_map do |option|
|
|
168
|
+
option['slug'] if option.is_a?(Hash) && !option['slug'].to_s.empty?
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Only an explicit `false` opens a custom field to writes. A definition
|
|
173
|
+
# carrying no flag at all is left read-only and reported: this datasource
|
|
174
|
+
# advertises nothing an endpoint would refuse, and reading the absence as
|
|
175
|
+
# "writable" would turn every field synced from an app into an editor whose
|
|
176
|
+
# every save Pylon rejects -- where reading it as "read-only" costs the
|
|
177
|
+
# capability and says so once per boot.
|
|
178
|
+
def writable_definition?(raw)
|
|
179
|
+
return true if raw['is_read_only'] == false
|
|
180
|
+
return false if raw['is_read_only'] == true
|
|
181
|
+
|
|
182
|
+
warn_unflagged_writability(raw)
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def warn_unflagged_writability(raw)
|
|
187
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
188
|
+
"[forest_admin_datasource_pylon] Custom field '#{raw["slug"]}' carries no 'is_read_only' flag; " \
|
|
189
|
+
'leaving it read-only. Pylon refuses a write on the fields it syncs from an app or an integration, ' \
|
|
190
|
+
'and nothing here can tell this one apart from those without the flag.'
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def warn_unknown_type(raw, slug, object_type)
|
|
195
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
196
|
+
"[forest_admin_datasource_pylon] Custom field '#{slug}' on #{object_type} has type " \
|
|
197
|
+
"#{raw["type"].inspect}, which this datasource cannot map to a Forest column; skipping."
|
|
198
|
+
)
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
# Holds a request until its endpoint has budget. A middleware rather than a
|
|
3
|
+
# call in each client method: there is one code path for every request here,
|
|
4
|
+
# where the client has twenty, and this one also catches the requests the
|
|
5
|
+
# client never issues itself — the replays `retry` performs.
|
|
6
|
+
class Throttle < Faraday::Middleware
|
|
7
|
+
def initialize(app, limiter:, base_path: '')
|
|
8
|
+
super(app)
|
|
9
|
+
@limiter = limiter
|
|
10
|
+
@base_path = base_path.to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Faraday hands over the path of the resolved url, so a base url mounted
|
|
14
|
+
# under a subpath carries that prefix and no rule matches. It comes off
|
|
15
|
+
# before the limiter sees the path.
|
|
16
|
+
def call(env)
|
|
17
|
+
@limiter.acquire(env.method, env.url.path.delete_prefix(@base_path))
|
|
18
|
+
@app.call(env)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require_relative 'forest_admin_datasource_pylon/version'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'set'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'zeitwerk'
|
|
7
|
+
require 'faraday'
|
|
8
|
+
require 'faraday/retry'
|
|
9
|
+
require 'forest_admin_datasource_toolkit'
|
|
10
|
+
|
|
11
|
+
loader = Zeitwerk::Loader.for_gem
|
|
12
|
+
loader.setup
|
|
13
|
+
|
|
14
|
+
module ForestAdminDatasourcePylon
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
class ConfigurationError < Error; end
|
|
17
|
+
|
|
18
|
+
# A filter Pylon cannot express. It descends from the toolkit's ValidationError
|
|
19
|
+
# rather than from the package's own Error so the agent answers 400 carrying
|
|
20
|
+
# the message instead of a 500 "Unexpected error": every one of these names a
|
|
21
|
+
# condition the operator set and can change, and the message is the only place
|
|
22
|
+
# they learn which one.
|
|
23
|
+
class UnsupportedOperatorError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end
|
|
24
|
+
|
|
25
|
+
# The three write errors below descend from ValidationError for that same
|
|
26
|
+
# reason: each names something the operator did and can undo.
|
|
27
|
+
|
|
28
|
+
# A verb Pylon's API has no endpoint for, a field it only accepts in the other
|
|
29
|
+
# direction, or a write reaching more records than one pass may cover.
|
|
30
|
+
class UnsupportedWriteError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end
|
|
31
|
+
|
|
32
|
+
# A write Pylon performed on some of its records and then failed on: one
|
|
33
|
+
# record is one request, so the ones before the failure stay written, and a
|
|
34
|
+
# retry of the whole selection would write them a second time.
|
|
35
|
+
class PartialWriteError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end
|
|
36
|
+
|
|
37
|
+
# A write Pylon itself refused, carrying the reason it gave — the likeliest
|
|
38
|
+
# way a write fails. Only its 4xx travels this way: a 5xx or a dropped
|
|
39
|
+
# connection is not the operator's to act on and stays the APIError it was.
|
|
40
|
+
class WriteRejectedError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end
|
|
41
|
+
|
|
42
|
+
# Raised when a Pylon API call fails. Carries the HTTP status and the
|
|
43
|
+
# (parsed) response body so callers — smart actions in particular — can
|
|
44
|
+
# surface Pylon's own validation message instead of a generic string.
|
|
45
|
+
class APIError < Error
|
|
46
|
+
attr_reader :status, :body
|
|
47
|
+
|
|
48
|
+
def initialize(message, status: nil, body: nil)
|
|
49
|
+
super(message)
|
|
50
|
+
@status = status
|
|
51
|
+
@body = body
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
attr_writer :logger
|
|
57
|
+
|
|
58
|
+
def logger
|
|
59
|
+
@logger ||= default_logger
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def default_logger
|
|
65
|
+
return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
66
|
+
|
|
67
|
+
Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_pylon' }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: forest_admin_datasource_pylon
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.41.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Forest Admin
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: faraday
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: faraday-retry
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: zeitwerk
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '2.3'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '2.3'
|
|
69
|
+
description: Surface Pylon issues, accounts, contacts, users and teams as Forest Admin
|
|
70
|
+
collections.
|
|
71
|
+
email:
|
|
72
|
+
- contact@forestadmin.com
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- ".rspec"
|
|
78
|
+
- README.md
|
|
79
|
+
- Rakefile
|
|
80
|
+
- forest_admin_datasource_pylon.gemspec
|
|
81
|
+
- lib/forest_admin_datasource_pylon.rb
|
|
82
|
+
- lib/forest_admin_datasource_pylon/client.rb
|
|
83
|
+
- lib/forest_admin_datasource_pylon/client/writes.rb
|
|
84
|
+
- lib/forest_admin_datasource_pylon/collections/account.rb
|
|
85
|
+
- lib/forest_admin_datasource_pylon/collections/account/api_filters.rb
|
|
86
|
+
- lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb
|
|
87
|
+
- lib/forest_admin_datasource_pylon/collections/account/serializer.rb
|
|
88
|
+
- lib/forest_admin_datasource_pylon/collections/base_collection.rb
|
|
89
|
+
- lib/forest_admin_datasource_pylon/collections/contact.rb
|
|
90
|
+
- lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb
|
|
91
|
+
- lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb
|
|
92
|
+
- lib/forest_admin_datasource_pylon/collections/contact/serializer.rb
|
|
93
|
+
- lib/forest_admin_datasource_pylon/collections/cursor_collection.rb
|
|
94
|
+
- lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb
|
|
95
|
+
- lib/forest_admin_datasource_pylon/collections/issue.rb
|
|
96
|
+
- lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb
|
|
97
|
+
- lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb
|
|
98
|
+
- lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb
|
|
99
|
+
- lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb
|
|
100
|
+
- lib/forest_admin_datasource_pylon/collections/issue/serializer.rb
|
|
101
|
+
- lib/forest_admin_datasource_pylon/collections/record_serialization.rb
|
|
102
|
+
- lib/forest_admin_datasource_pylon/collections/relation_embedder.rb
|
|
103
|
+
- lib/forest_admin_datasource_pylon/collections/team.rb
|
|
104
|
+
- lib/forest_admin_datasource_pylon/collections/user.rb
|
|
105
|
+
- lib/forest_admin_datasource_pylon/collections/writes.rb
|
|
106
|
+
- lib/forest_admin_datasource_pylon/configuration.rb
|
|
107
|
+
- lib/forest_admin_datasource_pylon/datasource.rb
|
|
108
|
+
- lib/forest_admin_datasource_pylon/issue_enums.rb
|
|
109
|
+
- lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb
|
|
110
|
+
- lib/forest_admin_datasource_pylon/plugins/close_issue.rb
|
|
111
|
+
- lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb
|
|
112
|
+
- lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb
|
|
113
|
+
- lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb
|
|
114
|
+
- lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb
|
|
115
|
+
- lib/forest_admin_datasource_pylon/plugins/issue_targets.rb
|
|
116
|
+
- lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb
|
|
117
|
+
- lib/forest_admin_datasource_pylon/query/filter_value.rb
|
|
118
|
+
- lib/forest_admin_datasource_pylon/query/operator_maps.rb
|
|
119
|
+
- lib/forest_admin_datasource_pylon/rate_limiter.rb
|
|
120
|
+
- lib/forest_admin_datasource_pylon/rate_limits.rb
|
|
121
|
+
- lib/forest_admin_datasource_pylon/retry_policy.rb
|
|
122
|
+
- lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb
|
|
123
|
+
- lib/forest_admin_datasource_pylon/throttle.rb
|
|
124
|
+
- lib/forest_admin_datasource_pylon/version.rb
|
|
125
|
+
homepage: https://www.forestadmin.com
|
|
126
|
+
licenses:
|
|
127
|
+
- GPL-3.0
|
|
128
|
+
metadata:
|
|
129
|
+
homepage_uri: https://www.forestadmin.com
|
|
130
|
+
source_code_uri: https://github.com/ForestAdmin/agent-ruby
|
|
131
|
+
changelog_uri: https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md
|
|
132
|
+
rubygems_mfa_required: 'false'
|
|
133
|
+
post_install_message:
|
|
134
|
+
rdoc_options: []
|
|
135
|
+
require_paths:
|
|
136
|
+
- lib
|
|
137
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
138
|
+
requirements:
|
|
139
|
+
- - ">="
|
|
140
|
+
- !ruby/object:Gem::Version
|
|
141
|
+
version: 3.0.0
|
|
142
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
143
|
+
requirements:
|
|
144
|
+
- - ">="
|
|
145
|
+
- !ruby/object:Gem::Version
|
|
146
|
+
version: '0'
|
|
147
|
+
requirements: []
|
|
148
|
+
rubygems_version: 3.4.20
|
|
149
|
+
signing_key:
|
|
150
|
+
specification_version: 4
|
|
151
|
+
summary: Pylon datasource for Forest Admin Ruby agent.
|
|
152
|
+
test_files: []
|