permittable 0.7.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.
@@ -11,11 +11,38 @@ module Permittable
11
11
  # Everything the exporter cannot know is left visible rather than guessed:
12
12
  # actions covered only by a catch-all rule on a host without
13
13
  # `action_methods` appear under the "*" key with `x-permittable-catch-all`,
14
- # and operations with no matching route land in `x-permittable-controllers`
15
- # instead of being dropped silently.
14
+ # and operations with no matching route — or whose path+verb slot another
15
+ # controller already claimed, which a document cannot represent twice —
16
+ # land in `x-permittable-controllers` instead of being dropped silently.
16
17
  module OpenAPI
17
18
  module_function
18
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
+
19
46
  # The error envelope rendered by render_invalid_parameters (see
20
47
  # ErrorEnvelope): code/details are present on every violation this gem
21
48
  # raises, message always.
@@ -28,24 +55,7 @@ module Permittable
28
55
  "properties" => {
29
56
  "message" => { "type" => "string" },
30
57
  "code" => { "type" => "string", "enum" => ["invalid_parameters"] },
31
- "details" => {
32
- "type" => "array",
33
- "items" => {
34
- "type" => "object",
35
- "properties" => {
36
- "param" => {
37
- "type" => "string",
38
- "description" => "Fully-qualified parameter path, e.g. user.address.zip or line_items[1].sku"
39
- },
40
- "code" => {
41
- "type" => "string",
42
- "description" => "missing / invalid_type / inclusion / format / length / unknown / invalid, " \
43
- "or a contract-specific symbol"
44
- }
45
- },
46
- "required" => %w[param code]
47
- }
48
- }
58
+ "details" => { "type" => "array", "items" => VIOLATION_SCHEMA }
49
59
  },
50
60
  "required" => %w[message]
51
61
  }
@@ -53,6 +63,25 @@ module Permittable
53
63
  "required" => %w[success error]
54
64
  }.freeze
55
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
+
56
85
  # Instance methods the concern itself adds to every including controller;
57
86
  # action_methods reports them as actions (they are public by design), but
58
87
  # they are never routed and must not be documented as endpoints. Resolved
@@ -62,9 +91,15 @@ module Permittable
62
91
  end
63
92
 
64
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.
65
100
  def components
66
101
  {
67
- "schemas" => { "PermittableInvalidParameters" => ERROR_SCHEMA },
102
+ "schemas" => { "PermittableInvalidParameters" => error_schema },
68
103
  "responses" => {
69
104
  "PermittableBadRequest" => error_response(
70
105
  "The root: key is missing or not an object — the request envelope itself is malformed."
@@ -76,11 +111,23 @@ module Permittable
76
111
  }
77
112
  end
78
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
+
79
126
  def error_response(description)
80
127
  {
81
128
  "description" => description,
82
129
  "content" => {
83
- "application/json" => {
130
+ error_media_type => {
84
131
  "schema" => { "$ref" => "#/components/schemas/PermittableInvalidParameters" }
85
132
  }
86
133
  }
@@ -157,12 +204,17 @@ module Permittable
157
204
  def document(controllers:, info: {}, routes: nil)
158
205
  paths = {}
159
206
  unrouted = {}
160
- 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|
161
212
  operations = operations_for(controller)
162
213
  next if operations.empty?
163
214
 
164
- place_operations(controller, operations, routes, paths, unrouted)
215
+ slots.concat(place_operations(controller, operations, targets, paths, unrouted))
165
216
  end
217
+ assign_unique_operation_ids(slots)
166
218
  doc = {
167
219
  "openapi" => "3.1.0",
168
220
  "info" => { "title" => "Permittable contracts", "version" => VERSION }.merge(info),
@@ -173,38 +225,226 @@ module Permittable
173
225
  doc
174
226
  end
175
227
 
176
- 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)
177
249
  key = controller_key(controller) || controller.inspect
178
- operations.each do |action, operation|
179
- matched = routes_for(routes, key, action)
180
- if matched.empty?
181
- (unrouted[key] ||= {})[action] = operation
250
+ operations.each_with_object([]) do |(action, operation), slots|
251
+ owner = [key, action]
252
+ # A path+verb pair carries exactly one operation, so a slot another
253
+ # controller already claimed is not written over: the loser stays
254
+ # visible under x-permittable-controllers, where an operation with no
255
+ # route at all lands, rather than disappearing from the document.
256
+ free = action == "*" ? [] : targets.fetch(owner, []).reject { |path, verb| paths.dig(path, verb) }
257
+ if free.empty?
258
+ holder = (unrouted[key] ||= {})
259
+ slots << OperationSlot.new(holder, action, nil, operation["operationId"], owner) unless holder.key?(action)
260
+ holder[action] = operation
182
261
  else
183
- matched.each { |route| (paths[route[:path]] ||= {})[route[:verb].to_s.downcase] = operation }
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
184
267
  end
185
268
  end
186
269
  end
187
270
 
188
- def routes_for(routes, controller_key, action)
189
- return [] if routes.nil? || action == "*"
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?
190
301
 
191
- routes.select { |r| r[:controller].to_s == controller_key && r[:action].to_s == action }
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
192
313
  end
193
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
+
340
+ def verb_of(route)
341
+ route[:verb].to_s.downcase
342
+ end
343
+
344
+ # OpenAPI 3.1 requires every variable in a path template to be declared as
345
+ # a path parameter — a document templating {id} without declaring it is
346
+ # invalid, which every member route produced. The route set does not say
347
+ # what an :id is and the exporter does not guess: a path segment arrives as
348
+ # a string, so that is what it is documented as.
349
+ def with_path_parameters(operation, path)
350
+ variables = path.scan(/\{(\w+)\}/).flatten
351
+ return operation if variables.empty?
352
+
353
+ parameters = variables.map do |name|
354
+ { "name" => name, "in" => "path", "required" => true, "schema" => { "type" => "string" } }
355
+ end
356
+ # Inserted ahead of requestBody, where a reader of the document expects
357
+ # it; emission stays deterministic either way.
358
+ operation.each_with_object({}) do |(key, value), out|
359
+ out["parameters"] = parameters if key == "requestBody"
360
+ out[key] = value
361
+ end
362
+ end
363
+
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
367
+
194
368
  # { controller:, action:, verb:, path: } descriptors from a Rails
195
369
  # application's route set. Duck-typed against Journey routes (each one
196
370
  # responds to requirements / verb / path.spec) so it stays unit-testable
197
- # without Rails; Rails path params (:id) become OpenAPI templates ({id}).
371
+ # without Rails; Rails path params become OpenAPI templates — both the
372
+ # `:id` form and the `*rest` wildcard, which is a real route shape
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.
198
385
  def rails_routes(app)
199
- app.routes.routes.filter_map do |route|
386
+ descriptors = app.routes.routes.each_with_index.flat_map do |route, index|
200
387
  requirements = route.requirements
201
388
  verb = route.verb.to_s
202
- 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?
203
395
 
204
- path = route.path.spec.to_s.sub("(.:format)", "").gsub(/:(\w+)/) { "{#{Regexp.last_match(1)}}" }
205
- { controller: requirements[:controller], action: requirements[:action],
206
- verb: verb.split("|").first.downcase, path: path }
396
+ path = route.path.spec.to_s.sub("(.:format)", "").gsub(/[:*](\w+)/) { "{#{Regexp.last_match(1)}}" }
397
+ # One route can answer several verbs (`match via: [:patch, :put]`, and
398
+ # the PATCH|PUT pair resources generates); documenting only the first
399
+ # dropped the others from the export entirely.
400
+ verb.split("|").map do |single|
401
+ { controller: requirements[:controller], action: requirements[:action],
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
445
+ end
207
446
  end
447
+ [variants, pos]
208
448
  end
209
449
 
210
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