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.
@@ -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" => ERROR_SCHEMA },
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
- "application/json" => {
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
- controllers.each do |controller|
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, routes, paths, unrouted)
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
- def place_operations(controller, operations, routes, paths, unrouted)
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.each do |action, operation|
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 = routes_for(routes, key, action).reject { |route| paths.dig(route[:path], verb_of(route)) }
264
+ free = action == "*" ? [] : targets.fetch(owner, []).reject { |path, verb| paths.dig(path, verb) }
192
265
  if free.empty?
193
- (unrouted[key] ||= {})[action] = operation
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 { |route| (paths[route[:path]] ||= {})[verb_of(route)] = with_path_parameters(operation, route[:path]) }
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
- def routes_for(routes, controller_key, action)
225
- return [] if routes.nil? || action == "*"
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? || verb.empty?
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)
@@ -32,6 +32,7 @@ module Permittable
32
32
  rake_tasks do
33
33
  load File.expand_path("tasks/openapi.rake", __dir__)
34
34
  load File.expand_path("tasks/generate.rake", __dir__)
35
+ load File.expand_path("tasks/audit.rake", __dir__)
35
36
  end
36
37
  end
37
38
  end