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.
@@ -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,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
- "application/json" => {
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
- controllers.each do |controller|
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, routes, paths, unrouted)
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
- def place_operations(controller, operations, routes, paths, unrouted)
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.each do |action, operation|
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 = routes_for(routes, key, action).reject { |route| paths.dig(route[:path], verb_of(route)) }
256
+ free = action == "*" ? [] : targets.fetch(owner, []).reject { |path, verb| paths.dig(path, verb) }
192
257
  if free.empty?
193
- (unrouted[key] ||= {})[action] = operation
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 { |route| (paths[route[:path]] ||= {})[verb_of(route)] = with_path_parameters(operation, route[:path]) }
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
- 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
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? || verb.empty?
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)
@@ -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