permittable 0.8.0 → 0.10.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 +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +361 -33
- data/lib/permittable/audit.rb +268 -0
- data/lib/permittable/authored_values.rb +63 -0
- data/lib/permittable/column_guard.rb +133 -6
- data/lib/permittable/contract.rb +11 -2
- data/lib/permittable/error_envelope.rb +79 -4
- data/lib/permittable/field_group.rb +72 -0
- data/lib/permittable/generator.rb +822 -51
- data/lib/permittable/json_schema/ecma_pattern.rb +248 -0
- data/lib/permittable/json_schema.rb +252 -47
- data/lib/permittable/open_api.rb +247 -42
- data/lib/permittable/railtie.rb +1 -0
- data/lib/permittable/rspec.rb +451 -25
- data/lib/permittable/tasks/audit.rake +34 -0
- data/lib/permittable/tasks/generate.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +985 -116
- metadata +9 -4
data/lib/permittable/open_api.rb
CHANGED
|
@@ -17,6 +17,32 @@ module Permittable
|
|
|
17
17
|
module OpenAPI
|
|
18
18
|
module_function
|
|
19
19
|
|
|
20
|
+
# One violation, in whichever shape the app renders — the same
|
|
21
|
+
# { param:, code: } entry rides in the envelope's `details` and the
|
|
22
|
+
# problem document's `errors`.
|
|
23
|
+
VIOLATION_SCHEMA = {
|
|
24
|
+
"type" => "object",
|
|
25
|
+
"properties" => {
|
|
26
|
+
"param" => {
|
|
27
|
+
"type" => "string",
|
|
28
|
+
"description" => "Fully-qualified parameter path, e.g. user.address.zip or line_items[1].sku"
|
|
29
|
+
},
|
|
30
|
+
"code" => {
|
|
31
|
+
"type" => "string",
|
|
32
|
+
"description" => "missing / invalid_type / inclusion / format / length / depth / unknown / " \
|
|
33
|
+
"invalid, or a contract-specific symbol"
|
|
34
|
+
},
|
|
35
|
+
# Present only when the field declares `message:` or the app has
|
|
36
|
+
# I18n copy for the code; a violation without one keeps the bare
|
|
37
|
+
# { param:, code: } shape, so this is not required.
|
|
38
|
+
"message" => {
|
|
39
|
+
"type" => "string",
|
|
40
|
+
"description" => "Human-readable copy for this violation, when the contract or I18n supplies it"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"required" => %w[param code]
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
20
46
|
# The error envelope rendered by render_invalid_parameters (see
|
|
21
47
|
# ErrorEnvelope): code/details are present on every violation this gem
|
|
22
48
|
# raises, message always.
|
|
@@ -29,31 +55,7 @@ module Permittable
|
|
|
29
55
|
"properties" => {
|
|
30
56
|
"message" => { "type" => "string" },
|
|
31
57
|
"code" => { "type" => "string", "enum" => ["invalid_parameters"] },
|
|
32
|
-
"details" => {
|
|
33
|
-
"type" => "array",
|
|
34
|
-
"items" => {
|
|
35
|
-
"type" => "object",
|
|
36
|
-
"properties" => {
|
|
37
|
-
"param" => {
|
|
38
|
-
"type" => "string",
|
|
39
|
-
"description" => "Fully-qualified parameter path, e.g. user.address.zip or line_items[1].sku"
|
|
40
|
-
},
|
|
41
|
-
"code" => {
|
|
42
|
-
"type" => "string",
|
|
43
|
-
"description" => "missing / invalid_type / inclusion / format / length / depth / unknown / " \
|
|
44
|
-
"invalid, or a contract-specific symbol"
|
|
45
|
-
},
|
|
46
|
-
# Present only when the field declares `message:` or the app
|
|
47
|
-
# has I18n copy for the code; a violation without one keeps
|
|
48
|
-
# the bare { param:, code: } shape, so this is not required.
|
|
49
|
-
"message" => {
|
|
50
|
-
"type" => "string",
|
|
51
|
-
"description" => "Human-readable copy for this violation, when the contract or I18n supplies it"
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
"required" => %w[param code]
|
|
55
|
-
}
|
|
56
|
-
}
|
|
58
|
+
"details" => { "type" => "array", "items" => VIOLATION_SCHEMA }
|
|
57
59
|
},
|
|
58
60
|
"required" => %w[message]
|
|
59
61
|
}
|
|
@@ -61,6 +63,25 @@ module Permittable
|
|
|
61
63
|
"required" => %w[success error]
|
|
62
64
|
}.freeze
|
|
63
65
|
|
|
66
|
+
# RFC 9457 Problem Details, the shape rendered when
|
|
67
|
+
# `Permittable.error_format = :problem`. `type` and `instance` are
|
|
68
|
+
# URI-references; `errors` is the field-violation extension member.
|
|
69
|
+
PROBLEM_SCHEMA = {
|
|
70
|
+
"type" => "object",
|
|
71
|
+
"properties" => {
|
|
72
|
+
"type" => {
|
|
73
|
+
"type" => "string", "format" => "uri-reference",
|
|
74
|
+
"description" => "Problem type URI — \"about:blank\" unless the app sets Permittable.problem_base_uri"
|
|
75
|
+
},
|
|
76
|
+
"title" => { "type" => "string", "enum" => ["Invalid parameters", "Malformed request"] },
|
|
77
|
+
"status" => { "type" => "integer", "enum" => [400, 422] },
|
|
78
|
+
"detail" => { "type" => "string" },
|
|
79
|
+
"instance" => { "type" => "string", "format" => "uri-reference" },
|
|
80
|
+
"errors" => { "type" => "array", "items" => VIOLATION_SCHEMA }
|
|
81
|
+
},
|
|
82
|
+
"required" => %w[title status]
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
64
85
|
# Instance methods the concern itself adds to every including controller;
|
|
65
86
|
# action_methods reports them as actions (they are public by design), but
|
|
66
87
|
# they are never routed and must not be documented as endpoints. Resolved
|
|
@@ -70,9 +91,15 @@ module Permittable
|
|
|
70
91
|
end
|
|
71
92
|
|
|
72
93
|
# Shared `components` for any document referencing Permittable responses.
|
|
94
|
+
#
|
|
95
|
+
# Unlike a rule's monitor mode — which the exporter reads only from the
|
|
96
|
+
# contract, never from runtime configuration — the error FORMAT has no
|
|
97
|
+
# per-contract declaration to read: it is one app-wide setting, and an
|
|
98
|
+
# export runs inside the app that made it. Reading it is what keeps the
|
|
99
|
+
# documented response shape from drifting from the rendered one.
|
|
73
100
|
def components
|
|
74
101
|
{
|
|
75
|
-
"schemas" => { "PermittableInvalidParameters" =>
|
|
102
|
+
"schemas" => { "PermittableInvalidParameters" => error_schema },
|
|
76
103
|
"responses" => {
|
|
77
104
|
"PermittableBadRequest" => error_response(
|
|
78
105
|
"The root: key is missing or not an object — the request envelope itself is malformed."
|
|
@@ -84,11 +111,31 @@ module Permittable
|
|
|
84
111
|
}
|
|
85
112
|
end
|
|
86
113
|
|
|
114
|
+
def problem_format?
|
|
115
|
+
Permittable.error_format == :problem
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ERROR_SCHEMA/PROBLEM_SCHEMA are frozen, but `.freeze` is shallow — only
|
|
119
|
+
# the top-level Hash is frozen, not the Hashes nested inside it — so
|
|
120
|
+
# handing either constant out by reference let a caller mutate a nested
|
|
121
|
+
# level of ITS document and permanently corrupt the shared constant for
|
|
122
|
+
# every document generated for the rest of the process. `deep_dup` (the
|
|
123
|
+
# same ActiveSupport helper the contract registry uses to copy authored
|
|
124
|
+
# default:/example: values before freezing, see permittable.rb) gives
|
|
125
|
+
# every caller its own independent copy instead.
|
|
126
|
+
def error_schema
|
|
127
|
+
(problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA).deep_dup
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def error_media_type
|
|
131
|
+
problem_format? ? ErrorEnvelope::PROBLEM_MEDIA_TYPE : "application/json"
|
|
132
|
+
end
|
|
133
|
+
|
|
87
134
|
def error_response(description)
|
|
88
135
|
{
|
|
89
136
|
"description" => description,
|
|
90
137
|
"content" => {
|
|
91
|
-
|
|
138
|
+
error_media_type => {
|
|
92
139
|
"schema" => { "$ref" => "#/components/schemas/PermittableInvalidParameters" }
|
|
93
140
|
}
|
|
94
141
|
}
|
|
@@ -165,12 +212,17 @@ module Permittable
|
|
|
165
212
|
def document(controllers:, info: {}, routes: nil)
|
|
166
213
|
paths = {}
|
|
167
214
|
unrouted = {}
|
|
168
|
-
|
|
215
|
+
slots = []
|
|
216
|
+
targets = route_targets(routes)
|
|
217
|
+
# A class listed twice would find every slot its first pass claimed and
|
|
218
|
+
# land under x-permittable-controllers, colliding with itself there.
|
|
219
|
+
controllers.uniq(&:object_id).each do |controller|
|
|
169
220
|
operations = operations_for(controller)
|
|
170
221
|
next if operations.empty?
|
|
171
222
|
|
|
172
|
-
place_operations(controller, operations,
|
|
223
|
+
slots.concat(place_operations(controller, operations, targets, paths, unrouted))
|
|
173
224
|
end
|
|
225
|
+
assign_unique_operation_ids(slots)
|
|
174
226
|
doc = {
|
|
175
227
|
"openapi" => "3.1.0",
|
|
176
228
|
"info" => { "title" => "Permittable contracts", "version" => VERSION }.merge(info),
|
|
@@ -181,22 +233,118 @@ module Permittable
|
|
|
181
233
|
doc
|
|
182
234
|
end
|
|
183
235
|
|
|
184
|
-
|
|
236
|
+
# Where one operation sits in the document: a path+verb slot under
|
|
237
|
+
# `paths`, or an action under x-permittable-controllers (verb nil).
|
|
238
|
+
# `holder[field]` is the placed operation; `id` its natural operationId;
|
|
239
|
+
# `owner` the [controller key, action] it documents.
|
|
240
|
+
OperationSlot = Struct.new(:holder, :field, :verb, :id, :owner)
|
|
241
|
+
|
|
242
|
+
# { [controller, action] => [[path, verb], ...] }, in route order. Built
|
|
243
|
+
# once, with each verb normalised once: matching every operation against
|
|
244
|
+
# every route made placement quadratic in the size of the route set. A
|
|
245
|
+
# route declared twice names one slot, so it is kept once — not counted
|
|
246
|
+
# as an operation colliding with itself.
|
|
247
|
+
def route_targets(routes)
|
|
248
|
+
return {} if routes.nil?
|
|
249
|
+
|
|
250
|
+
routes.group_by { |route| [route[:controller].to_s, route[:action].to_s] }
|
|
251
|
+
.transform_values { |matching| matching.map { |route| [route[:path], verb_of(route)] }.uniq }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Places every operation and returns the slots it filled, in placement
|
|
255
|
+
# order (controller, action, route), for assign_unique_operation_ids.
|
|
256
|
+
def place_operations(controller, operations, targets, paths, unrouted)
|
|
185
257
|
key = controller_key(controller) || controller.inspect
|
|
186
|
-
operations.
|
|
258
|
+
operations.each_with_object([]) do |(action, operation), slots|
|
|
259
|
+
owner = [key, action]
|
|
187
260
|
# A path+verb pair carries exactly one operation, so a slot another
|
|
188
261
|
# controller already claimed is not written over: the loser stays
|
|
189
262
|
# visible under x-permittable-controllers, where an operation with no
|
|
190
263
|
# route at all lands, rather than disappearing from the document.
|
|
191
|
-
free =
|
|
264
|
+
free = action == "*" ? [] : targets.fetch(owner, []).reject { |path, verb| paths.dig(path, verb) }
|
|
192
265
|
if free.empty?
|
|
193
|
-
(unrouted[key] ||= {})
|
|
266
|
+
holder = (unrouted[key] ||= {})
|
|
267
|
+
slots << OperationSlot.new(holder, action, nil, operation["operationId"], owner) unless holder.key?(action)
|
|
268
|
+
holder[action] = operation
|
|
194
269
|
else
|
|
195
|
-
free.each
|
|
270
|
+
free.each do |path, verb|
|
|
271
|
+
holder = (paths[path] ||= {})
|
|
272
|
+
holder[verb] = with_path_parameters(operation, path)
|
|
273
|
+
slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner)
|
|
274
|
+
end
|
|
196
275
|
end
|
|
197
276
|
end
|
|
198
277
|
end
|
|
199
278
|
|
|
279
|
+
# OpenAPI requires operationId to be unique across the document, and
|
|
280
|
+
# client generators name a method after it — a duplicate is an invalid
|
|
281
|
+
# document and, in practice, two methods with one name. One operation is
|
|
282
|
+
# placed at every slot its routes reach: the separate PATCH and PUT
|
|
283
|
+
# routes `resources` draws to update, or one `via: [:patch, :put]` route;
|
|
284
|
+
# with the optional-segment expansion, one route's several paths; with
|
|
285
|
+
# `via: :all` routes, which are documented under each verb, five verbs.
|
|
286
|
+
# And `key.tr("/", "_")` folds admin/users and admin_users into one id.
|
|
287
|
+
#
|
|
288
|
+
# Renaming the scheme would rename every generated client method, so an
|
|
289
|
+
# id that is already unique never changes. Every slot's natural id is
|
|
290
|
+
# known before any is renamed and all of them are reserved, so a suffix
|
|
291
|
+
# can never take a name that is another operation's own id — that
|
|
292
|
+
# operation would otherwise be renamed for a collision it never had.
|
|
293
|
+
# Within a colliding group the first slot keeps the plain id. Another
|
|
294
|
+
# takes its verb (users_update_put) when that verb differs from the plain
|
|
295
|
+
# id's and the suffixed id is free; otherwise it is numbered
|
|
296
|
+
# (posts_create_2 for a second POST, users_update_2 for a second PATCH).
|
|
297
|
+
# The suffix names how the slot differs from the plain one, so a verb
|
|
298
|
+
# the two share would say nothing true.
|
|
299
|
+
#
|
|
300
|
+
# Unrouted operations take part: x-permittable-controllers is in the same
|
|
301
|
+
# document and feeds the same generators. They yield the plain id to a
|
|
302
|
+
# routed operation, though, since `paths` is what a client calls.
|
|
303
|
+
def assign_unique_operation_ids(slots)
|
|
304
|
+
slots = slots.select(&:id)
|
|
305
|
+
taken = slots.to_set(&:id)
|
|
306
|
+
next_number = Hash.new(2)
|
|
307
|
+
slots.group_by(&:id).each_value do |group|
|
|
308
|
+
next if group.one?
|
|
309
|
+
|
|
310
|
+
plain, *renamed = collision_order(group)
|
|
311
|
+
renamed.each do |slot|
|
|
312
|
+
by_verb = "#{slot.id}_#{slot.verb}"
|
|
313
|
+
by_verb = nil if slot.verb.nil? || slot.verb == plain.verb || taken.include?(by_verb)
|
|
314
|
+
unique = by_verb || numbered_id(slot.id, taken, next_number)
|
|
315
|
+
taken << unique
|
|
316
|
+
# The same operation object may sit at other slots under its own
|
|
317
|
+
# id, so the rename goes on a copy; merge keeps the key order.
|
|
318
|
+
slot.holder[slot.field] = slot.holder[slot.field].merge("operationId" => unique)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Routed before unrouted, then placement order: controller, action, route.
|
|
324
|
+
# Within one operation a PUT sorts after its PATCH, because
|
|
325
|
+
# `match via: [:put, :patch]` lists PUT first where `resources` lists
|
|
326
|
+
# PATCH first, and without this one pair of routes would name the PATCH
|
|
327
|
+
# method two ways. Across operations the order alone decides: a PATCH in
|
|
328
|
+
# a later controller does not take the plain id from an earlier PUT.
|
|
329
|
+
def collision_order(group)
|
|
330
|
+
patched = group.select { |slot| slot.verb == "patch" }.to_set(&:owner)
|
|
331
|
+
first_at = {}
|
|
332
|
+
group.each_with_index { |slot, index| first_at[slot.owner] ||= index }
|
|
333
|
+
group.each_with_index.sort_by do |slot, index|
|
|
334
|
+
put_after_patch = slot.verb == "put" && patched.include?(slot.owner) ? 1 : 0
|
|
335
|
+
[slot.verb ? 0 : 1, first_at[slot.owner], put_after_patch, index]
|
|
336
|
+
end.map(&:first)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# The next free "#{id}_n", n from 2. The counter per id means no number
|
|
340
|
+
# is tried twice for one id, which keeps the pass linear.
|
|
341
|
+
def numbered_id(id, taken, next_number)
|
|
342
|
+
number = next_number[id]
|
|
343
|
+
number += 1 while taken.include?("#{id}_#{number}")
|
|
344
|
+
next_number[id] = number + 1
|
|
345
|
+
"#{id}_#{number}"
|
|
346
|
+
end
|
|
347
|
+
|
|
200
348
|
def verb_of(route)
|
|
201
349
|
route[:verb].to_s.downcase
|
|
202
350
|
end
|
|
@@ -221,11 +369,9 @@ module Permittable
|
|
|
221
369
|
end
|
|
222
370
|
end
|
|
223
371
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
routes.select { |r| r[:controller].to_s == controller_key && r[:action].to_s == action }
|
|
228
|
-
end
|
|
372
|
+
# What a `via: :all` route answers, in the "|"-joined form Journey uses
|
|
373
|
+
# for a route with several verbs.
|
|
374
|
+
ALL_VERBS = "GET|POST|PUT|PATCH|DELETE".freeze
|
|
229
375
|
|
|
230
376
|
# { controller:, action:, verb:, path: } descriptors from a Rails
|
|
231
377
|
# application's route set. Duck-typed against Journey routes (each one
|
|
@@ -233,11 +379,27 @@ module Permittable
|
|
|
233
379
|
# without Rails; Rails path params become OpenAPI templates — both the
|
|
234
380
|
# `:id` form and the `*rest` wildcard, which is a real route shape
|
|
235
381
|
# (`get "files/*path"`) and is not a valid OpenAPI template left as-is.
|
|
382
|
+
#
|
|
383
|
+
# An optional group is expanded into the concrete paths it stands for:
|
|
384
|
+
# parentheses are not valid in an OpenAPI path template, so leaving
|
|
385
|
+
# `scope "(:locale)"` as `(/{locale})/posts` made the whole document fail
|
|
386
|
+
# validation. Each variant is its own descriptor, so each path's variables
|
|
387
|
+
# are required there — which, for that path, they are.
|
|
388
|
+
#
|
|
389
|
+
# Each descriptor also carries `route:`, the index of the route it came
|
|
390
|
+
# from, so a reader counting routes rather than paths (Audit.summary) can
|
|
391
|
+
# tell one route's expanded variants from a second route that happens to
|
|
392
|
+
# reach the same action. The exporter ignores it.
|
|
236
393
|
def rails_routes(app)
|
|
237
|
-
app.routes.routes.flat_map do |route|
|
|
394
|
+
descriptors = app.routes.routes.each_with_index.flat_map do |route, index|
|
|
238
395
|
requirements = route.requirements
|
|
239
396
|
verb = route.verb.to_s
|
|
240
|
-
next [] if requirements[:controller].nil? || requirements[:action].nil?
|
|
397
|
+
next [] if requirements[:controller].nil? || requirements[:action].nil?
|
|
398
|
+
|
|
399
|
+
# `match ..., via: :all` leaves the verb EMPTY rather than listing
|
|
400
|
+
# them, and skipping it hid an action that takes POST bodies from the
|
|
401
|
+
# audit entirely. Expand it into the verbs it actually answers.
|
|
402
|
+
verb = ALL_VERBS if verb.empty?
|
|
241
403
|
|
|
242
404
|
path = route.path.spec.to_s.sub("(.:format)", "").gsub(/[:*](\w+)/) { "{#{Regexp.last_match(1)}}" }
|
|
243
405
|
# One route can answer several verbs (`match via: [:patch, :put]`, and
|
|
@@ -245,9 +407,52 @@ module Permittable
|
|
|
245
407
|
# dropped the others from the export entirely.
|
|
246
408
|
verb.split("|").map do |single|
|
|
247
409
|
{ controller: requirements[:controller], action: requirements[:action],
|
|
248
|
-
verb: single.downcase, path: path }
|
|
410
|
+
verb: single.downcase, path: path, route: index }
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
descriptors.flat_map do |descriptor|
|
|
414
|
+
optional_variants(descriptor[:path]).map { |variant| descriptor.merge(path: variant) }
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Every concrete path an optionally-grouped template stands for:
|
|
419
|
+
# `/archive(/{year}(/{month}))` → `/archive`, `/archive/{year}`,
|
|
420
|
+
# `/archive/{year}/{month}`. Rails fills groups left to right, so
|
|
421
|
+
# `/x(/{a})(/{b})` with one segment present is always `/x/{a}` — the
|
|
422
|
+
# `/x/{b}` variant is the same URL under another name, and OpenAPI forbids
|
|
423
|
+
# two templates differing only in variable names. Variants are built with
|
|
424
|
+
# each group present first, so the one Rails would match is the one kept;
|
|
425
|
+
# the list is then reversed, which puts every group's ABSENT variant
|
|
426
|
+
# first at every nesting level. That order matters to the operationId
|
|
427
|
+
# dedupe (assign_unique_operation_ids, which numbers colliding ids in
|
|
428
|
+
# route order): the variants of one route share an operation, and it is
|
|
429
|
+
# that dedupe, not this expansion, that makes their ids unique. Absent
|
|
430
|
+
# first means `/posts` keeps `posts_create` and `/{locale}/posts` takes
|
|
431
|
+
# the suffix.
|
|
432
|
+
def optional_variants(path)
|
|
433
|
+
variants, = expand_optional_groups(path, 0)
|
|
434
|
+
variants.map { |variant| variant.empty? ? "/" : variant }
|
|
435
|
+
.uniq { |variant| variant.gsub(/\{\w+\}/, "{}") }
|
|
436
|
+
.reverse
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Walks the template from `pos` to the matching `)` (or the end),
|
|
440
|
+
# returning the variants of that stretch and the position after it.
|
|
441
|
+
def expand_optional_groups(path, pos)
|
|
442
|
+
variants = [+""]
|
|
443
|
+
while pos < path.length
|
|
444
|
+
char = path[pos]
|
|
445
|
+
if char == "("
|
|
446
|
+
inner, pos = expand_optional_groups(path, pos + 1)
|
|
447
|
+
variants = variants.product(inner + [""]).map(&:join)
|
|
448
|
+
elsif char == ")"
|
|
449
|
+
return [variants, pos + 1]
|
|
450
|
+
else
|
|
451
|
+
variants.each { |variant| variant << char }
|
|
452
|
+
pos += 1
|
|
249
453
|
end
|
|
250
454
|
end
|
|
455
|
+
[variants, pos]
|
|
251
456
|
end
|
|
252
457
|
|
|
253
458
|
def controller_key(controller)
|
data/lib/permittable/railtie.rb
CHANGED