permittable 0.8.0 → 0.9.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 +137 -0
- data/README.md +356 -30
- data/lib/permittable/audit.rb +268 -0
- data/lib/permittable/authored_values.rb +47 -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 +802 -50
- data/lib/permittable/json_schema/ecma_pattern.rb +248 -0
- data/lib/permittable/json_schema.rb +226 -45
- data/lib/permittable/open_api.rb +239 -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 +932 -110
- 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,23 @@ module Permittable
|
|
|
84
111
|
}
|
|
85
112
|
end
|
|
86
113
|
|
|
114
|
+
def problem_format?
|
|
115
|
+
Permittable.error_format == :problem
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def error_schema
|
|
119
|
+
problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def error_media_type
|
|
123
|
+
problem_format? ? ErrorEnvelope::PROBLEM_MEDIA_TYPE : "application/json"
|
|
124
|
+
end
|
|
125
|
+
|
|
87
126
|
def error_response(description)
|
|
88
127
|
{
|
|
89
128
|
"description" => description,
|
|
90
129
|
"content" => {
|
|
91
|
-
|
|
130
|
+
error_media_type => {
|
|
92
131
|
"schema" => { "$ref" => "#/components/schemas/PermittableInvalidParameters" }
|
|
93
132
|
}
|
|
94
133
|
}
|
|
@@ -165,12 +204,17 @@ module Permittable
|
|
|
165
204
|
def document(controllers:, info: {}, routes: nil)
|
|
166
205
|
paths = {}
|
|
167
206
|
unrouted = {}
|
|
168
|
-
|
|
207
|
+
slots = []
|
|
208
|
+
targets = route_targets(routes)
|
|
209
|
+
# A class listed twice would find every slot its first pass claimed and
|
|
210
|
+
# land under x-permittable-controllers, colliding with itself there.
|
|
211
|
+
controllers.uniq(&:object_id).each do |controller|
|
|
169
212
|
operations = operations_for(controller)
|
|
170
213
|
next if operations.empty?
|
|
171
214
|
|
|
172
|
-
place_operations(controller, operations,
|
|
215
|
+
slots.concat(place_operations(controller, operations, targets, paths, unrouted))
|
|
173
216
|
end
|
|
217
|
+
assign_unique_operation_ids(slots)
|
|
174
218
|
doc = {
|
|
175
219
|
"openapi" => "3.1.0",
|
|
176
220
|
"info" => { "title" => "Permittable contracts", "version" => VERSION }.merge(info),
|
|
@@ -181,22 +225,118 @@ module Permittable
|
|
|
181
225
|
doc
|
|
182
226
|
end
|
|
183
227
|
|
|
184
|
-
|
|
228
|
+
# Where one operation sits in the document: a path+verb slot under
|
|
229
|
+
# `paths`, or an action under x-permittable-controllers (verb nil).
|
|
230
|
+
# `holder[field]` is the placed operation; `id` its natural operationId;
|
|
231
|
+
# `owner` the [controller key, action] it documents.
|
|
232
|
+
OperationSlot = Struct.new(:holder, :field, :verb, :id, :owner)
|
|
233
|
+
|
|
234
|
+
# { [controller, action] => [[path, verb], ...] }, in route order. Built
|
|
235
|
+
# once, with each verb normalised once: matching every operation against
|
|
236
|
+
# every route made placement quadratic in the size of the route set. A
|
|
237
|
+
# route declared twice names one slot, so it is kept once — not counted
|
|
238
|
+
# as an operation colliding with itself.
|
|
239
|
+
def route_targets(routes)
|
|
240
|
+
return {} if routes.nil?
|
|
241
|
+
|
|
242
|
+
routes.group_by { |route| [route[:controller].to_s, route[:action].to_s] }
|
|
243
|
+
.transform_values { |matching| matching.map { |route| [route[:path], verb_of(route)] }.uniq }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Places every operation and returns the slots it filled, in placement
|
|
247
|
+
# order (controller, action, route), for assign_unique_operation_ids.
|
|
248
|
+
def place_operations(controller, operations, targets, paths, unrouted)
|
|
185
249
|
key = controller_key(controller) || controller.inspect
|
|
186
|
-
operations.
|
|
250
|
+
operations.each_with_object([]) do |(action, operation), slots|
|
|
251
|
+
owner = [key, action]
|
|
187
252
|
# A path+verb pair carries exactly one operation, so a slot another
|
|
188
253
|
# controller already claimed is not written over: the loser stays
|
|
189
254
|
# visible under x-permittable-controllers, where an operation with no
|
|
190
255
|
# route at all lands, rather than disappearing from the document.
|
|
191
|
-
free =
|
|
256
|
+
free = action == "*" ? [] : targets.fetch(owner, []).reject { |path, verb| paths.dig(path, verb) }
|
|
192
257
|
if free.empty?
|
|
193
|
-
(unrouted[key] ||= {})
|
|
258
|
+
holder = (unrouted[key] ||= {})
|
|
259
|
+
slots << OperationSlot.new(holder, action, nil, operation["operationId"], owner) unless holder.key?(action)
|
|
260
|
+
holder[action] = operation
|
|
194
261
|
else
|
|
195
|
-
free.each
|
|
262
|
+
free.each do |path, verb|
|
|
263
|
+
holder = (paths[path] ||= {})
|
|
264
|
+
holder[verb] = with_path_parameters(operation, path)
|
|
265
|
+
slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner)
|
|
266
|
+
end
|
|
196
267
|
end
|
|
197
268
|
end
|
|
198
269
|
end
|
|
199
270
|
|
|
271
|
+
# OpenAPI requires operationId to be unique across the document, and
|
|
272
|
+
# client generators name a method after it — a duplicate is an invalid
|
|
273
|
+
# document and, in practice, two methods with one name. One operation is
|
|
274
|
+
# placed at every slot its routes reach: the separate PATCH and PUT
|
|
275
|
+
# routes `resources` draws to update, or one `via: [:patch, :put]` route;
|
|
276
|
+
# with the optional-segment expansion, one route's several paths; with
|
|
277
|
+
# `via: :all` routes, which are documented under each verb, five verbs.
|
|
278
|
+
# And `key.tr("/", "_")` folds admin/users and admin_users into one id.
|
|
279
|
+
#
|
|
280
|
+
# Renaming the scheme would rename every generated client method, so an
|
|
281
|
+
# id that is already unique never changes. Every slot's natural id is
|
|
282
|
+
# known before any is renamed and all of them are reserved, so a suffix
|
|
283
|
+
# can never take a name that is another operation's own id — that
|
|
284
|
+
# operation would otherwise be renamed for a collision it never had.
|
|
285
|
+
# Within a colliding group the first slot keeps the plain id. Another
|
|
286
|
+
# takes its verb (users_update_put) when that verb differs from the plain
|
|
287
|
+
# id's and the suffixed id is free; otherwise it is numbered
|
|
288
|
+
# (posts_create_2 for a second POST, users_update_2 for a second PATCH).
|
|
289
|
+
# The suffix names how the slot differs from the plain one, so a verb
|
|
290
|
+
# the two share would say nothing true.
|
|
291
|
+
#
|
|
292
|
+
# Unrouted operations take part: x-permittable-controllers is in the same
|
|
293
|
+
# document and feeds the same generators. They yield the plain id to a
|
|
294
|
+
# routed operation, though, since `paths` is what a client calls.
|
|
295
|
+
def assign_unique_operation_ids(slots)
|
|
296
|
+
slots = slots.select(&:id)
|
|
297
|
+
taken = slots.to_set(&:id)
|
|
298
|
+
next_number = Hash.new(2)
|
|
299
|
+
slots.group_by(&:id).each_value do |group|
|
|
300
|
+
next if group.one?
|
|
301
|
+
|
|
302
|
+
plain, *renamed = collision_order(group)
|
|
303
|
+
renamed.each do |slot|
|
|
304
|
+
by_verb = "#{slot.id}_#{slot.verb}"
|
|
305
|
+
by_verb = nil if slot.verb.nil? || slot.verb == plain.verb || taken.include?(by_verb)
|
|
306
|
+
unique = by_verb || numbered_id(slot.id, taken, next_number)
|
|
307
|
+
taken << unique
|
|
308
|
+
# The same operation object may sit at other slots under its own
|
|
309
|
+
# id, so the rename goes on a copy; merge keeps the key order.
|
|
310
|
+
slot.holder[slot.field] = slot.holder[slot.field].merge("operationId" => unique)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Routed before unrouted, then placement order: controller, action, route.
|
|
316
|
+
# Within one operation a PUT sorts after its PATCH, because
|
|
317
|
+
# `match via: [:put, :patch]` lists PUT first where `resources` lists
|
|
318
|
+
# PATCH first, and without this one pair of routes would name the PATCH
|
|
319
|
+
# method two ways. Across operations the order alone decides: a PATCH in
|
|
320
|
+
# a later controller does not take the plain id from an earlier PUT.
|
|
321
|
+
def collision_order(group)
|
|
322
|
+
patched = group.select { |slot| slot.verb == "patch" }.to_set(&:owner)
|
|
323
|
+
first_at = {}
|
|
324
|
+
group.each_with_index { |slot, index| first_at[slot.owner] ||= index }
|
|
325
|
+
group.each_with_index.sort_by do |slot, index|
|
|
326
|
+
put_after_patch = slot.verb == "put" && patched.include?(slot.owner) ? 1 : 0
|
|
327
|
+
[slot.verb ? 0 : 1, first_at[slot.owner], put_after_patch, index]
|
|
328
|
+
end.map(&:first)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# The next free "#{id}_n", n from 2. The counter per id means no number
|
|
332
|
+
# is tried twice for one id, which keeps the pass linear.
|
|
333
|
+
def numbered_id(id, taken, next_number)
|
|
334
|
+
number = next_number[id]
|
|
335
|
+
number += 1 while taken.include?("#{id}_#{number}")
|
|
336
|
+
next_number[id] = number + 1
|
|
337
|
+
"#{id}_#{number}"
|
|
338
|
+
end
|
|
339
|
+
|
|
200
340
|
def verb_of(route)
|
|
201
341
|
route[:verb].to_s.downcase
|
|
202
342
|
end
|
|
@@ -221,11 +361,9 @@ module Permittable
|
|
|
221
361
|
end
|
|
222
362
|
end
|
|
223
363
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
routes.select { |r| r[:controller].to_s == controller_key && r[:action].to_s == action }
|
|
228
|
-
end
|
|
364
|
+
# What a `via: :all` route answers, in the "|"-joined form Journey uses
|
|
365
|
+
# for a route with several verbs.
|
|
366
|
+
ALL_VERBS = "GET|POST|PUT|PATCH|DELETE".freeze
|
|
229
367
|
|
|
230
368
|
# { controller:, action:, verb:, path: } descriptors from a Rails
|
|
231
369
|
# application's route set. Duck-typed against Journey routes (each one
|
|
@@ -233,11 +371,27 @@ module Permittable
|
|
|
233
371
|
# without Rails; Rails path params become OpenAPI templates — both the
|
|
234
372
|
# `:id` form and the `*rest` wildcard, which is a real route shape
|
|
235
373
|
# (`get "files/*path"`) and is not a valid OpenAPI template left as-is.
|
|
374
|
+
#
|
|
375
|
+
# An optional group is expanded into the concrete paths it stands for:
|
|
376
|
+
# parentheses are not valid in an OpenAPI path template, so leaving
|
|
377
|
+
# `scope "(:locale)"` as `(/{locale})/posts` made the whole document fail
|
|
378
|
+
# validation. Each variant is its own descriptor, so each path's variables
|
|
379
|
+
# are required there — which, for that path, they are.
|
|
380
|
+
#
|
|
381
|
+
# Each descriptor also carries `route:`, the index of the route it came
|
|
382
|
+
# from, so a reader counting routes rather than paths (Audit.summary) can
|
|
383
|
+
# tell one route's expanded variants from a second route that happens to
|
|
384
|
+
# reach the same action. The exporter ignores it.
|
|
236
385
|
def rails_routes(app)
|
|
237
|
-
app.routes.routes.flat_map do |route|
|
|
386
|
+
descriptors = app.routes.routes.each_with_index.flat_map do |route, index|
|
|
238
387
|
requirements = route.requirements
|
|
239
388
|
verb = route.verb.to_s
|
|
240
|
-
next [] if requirements[:controller].nil? || requirements[:action].nil?
|
|
389
|
+
next [] if requirements[:controller].nil? || requirements[:action].nil?
|
|
390
|
+
|
|
391
|
+
# `match ..., via: :all` leaves the verb EMPTY rather than listing
|
|
392
|
+
# them, and skipping it hid an action that takes POST bodies from the
|
|
393
|
+
# audit entirely. Expand it into the verbs it actually answers.
|
|
394
|
+
verb = ALL_VERBS if verb.empty?
|
|
241
395
|
|
|
242
396
|
path = route.path.spec.to_s.sub("(.:format)", "").gsub(/[:*](\w+)/) { "{#{Regexp.last_match(1)}}" }
|
|
243
397
|
# One route can answer several verbs (`match via: [:patch, :put]`, and
|
|
@@ -245,9 +399,52 @@ module Permittable
|
|
|
245
399
|
# dropped the others from the export entirely.
|
|
246
400
|
verb.split("|").map do |single|
|
|
247
401
|
{ controller: requirements[:controller], action: requirements[:action],
|
|
248
|
-
verb: single.downcase, path: path }
|
|
402
|
+
verb: single.downcase, path: path, route: index }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
descriptors.flat_map do |descriptor|
|
|
406
|
+
optional_variants(descriptor[:path]).map { |variant| descriptor.merge(path: variant) }
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Every concrete path an optionally-grouped template stands for:
|
|
411
|
+
# `/archive(/{year}(/{month}))` → `/archive`, `/archive/{year}`,
|
|
412
|
+
# `/archive/{year}/{month}`. Rails fills groups left to right, so
|
|
413
|
+
# `/x(/{a})(/{b})` with one segment present is always `/x/{a}` — the
|
|
414
|
+
# `/x/{b}` variant is the same URL under another name, and OpenAPI forbids
|
|
415
|
+
# two templates differing only in variable names. Variants are built with
|
|
416
|
+
# each group present first, so the one Rails would match is the one kept;
|
|
417
|
+
# the list is then reversed, which puts every group's ABSENT variant
|
|
418
|
+
# first at every nesting level. That order matters to the operationId
|
|
419
|
+
# dedupe (assign_unique_operation_ids, which numbers colliding ids in
|
|
420
|
+
# route order): the variants of one route share an operation, and it is
|
|
421
|
+
# that dedupe, not this expansion, that makes their ids unique. Absent
|
|
422
|
+
# first means `/posts` keeps `posts_create` and `/{locale}/posts` takes
|
|
423
|
+
# the suffix.
|
|
424
|
+
def optional_variants(path)
|
|
425
|
+
variants, = expand_optional_groups(path, 0)
|
|
426
|
+
variants.map { |variant| variant.empty? ? "/" : variant }
|
|
427
|
+
.uniq { |variant| variant.gsub(/\{\w+\}/, "{}") }
|
|
428
|
+
.reverse
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Walks the template from `pos` to the matching `)` (or the end),
|
|
432
|
+
# returning the variants of that stretch and the position after it.
|
|
433
|
+
def expand_optional_groups(path, pos)
|
|
434
|
+
variants = [+""]
|
|
435
|
+
while pos < path.length
|
|
436
|
+
char = path[pos]
|
|
437
|
+
if char == "("
|
|
438
|
+
inner, pos = expand_optional_groups(path, pos + 1)
|
|
439
|
+
variants = variants.product(inner + [""]).map(&:join)
|
|
440
|
+
elsif char == ")"
|
|
441
|
+
return [variants, pos + 1]
|
|
442
|
+
else
|
|
443
|
+
variants.each { |variant| variant << char }
|
|
444
|
+
pos += 1
|
|
249
445
|
end
|
|
250
446
|
end
|
|
447
|
+
[variants, pos]
|
|
251
448
|
end
|
|
252
449
|
|
|
253
450
|
def controller_key(controller)
|
data/lib/permittable/railtie.rb
CHANGED