permittable 0.10.0 → 0.11.1

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.
@@ -12,8 +12,9 @@ module Permittable
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
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.
15
+ # controller already claimed, which a document cannot represent twice, or
16
+ # whose route an earlier route of the same URL shape shadows — land in
17
+ # `x-permittable-controllers` instead of being dropped silently.
17
18
  module OpenAPI
18
19
  module_function
19
20
 
@@ -187,7 +188,9 @@ module Permittable
187
188
  operation = {}
188
189
  key = controller_key(controller)
189
190
  operation["operationId"] = "#{key.tr('/', '_')}_#{action}" if key && action != "*"
190
- operation["description"] = rule[:desc] if rule[:desc]
191
+ # dup: the stored desc is frozen, and a caller editing the generated
192
+ # document in place must neither raise nor reach the contract.
193
+ operation["description"] = rule[:desc].dup if rule[:desc]
191
194
  operation["requestBody"] = rule_request_body(rule)
192
195
  operation["responses"] = responses_for(rule)
193
196
  # The docs must not promise a 422 the server doesn't yet send. Only
@@ -195,6 +198,12 @@ module Permittable
195
198
  # Permittable.mode is runtime configuration the export can't see.
196
199
  operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
197
200
  operation["x-permittable-catch-all"] = true if action == "*"
201
+ # `finalize` is flagged on the rule's object schema too (see
202
+ # JsonSchema.rule), but that sits inside the requestBody, and an
203
+ # operation can end up with no body (every field a path variable) while
204
+ # finalize still refuses requests. The operation is the one object that
205
+ # is always there, so the marker rides on it as well.
206
+ operation["x-permittable-finalize"] = true if rule[:finalize]
198
207
  operation
199
208
  end
200
209
 
@@ -213,6 +222,7 @@ module Permittable
213
222
  paths = {}
214
223
  unrouted = {}
215
224
  slots = []
225
+ routes, shadows = collapse_shapes(routes)
216
226
  targets = route_targets(routes)
217
227
  # A class listed twice would find every slot its first pass claimed and
218
228
  # land under x-permittable-controllers, colliding with itself there.
@@ -222,6 +232,7 @@ module Permittable
222
232
 
223
233
  slots.concat(place_operations(controller, operations, targets, paths, unrouted))
224
234
  end
235
+ note_shadows(paths, shadows)
225
236
  assign_unique_operation_ids(slots)
226
237
  doc = {
227
238
  "openapi" => "3.1.0",
@@ -234,25 +245,114 @@ module Permittable
234
245
  end
235
246
 
236
247
  # 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.
248
+ # `paths`, or an action under x-permittable-controllers (verb and path
249
+ # nil). `holder[field]` is the placed operation; `id` its natural
250
+ # operationId; `owner` the [controller key, action] it documents.
251
+ OperationSlot = Struct.new(:holder, :field, :verb, :id, :owner, :path)
252
+
253
+ # { [controller, action] => [[path, verb, template], ...] }, in route
254
+ # order. Built once, with each verb normalised once: matching every
255
+ # operation against every route made placement quadratic in the size of
256
+ # the route set. A route declared twice names one slot, so it is kept
257
+ # once — not counted as an operation colliding with itself. `template`
258
+ # is the route's own spelling when collapse_shapes placed it under
259
+ # another route's, nil otherwise.
260
+ #
261
+ # Only OpenAPI's own verbs name a slot. Rails routes any verb in
262
+ # ActionDispatch::Request::HTTP_METHODS — `via: :report`, `:mkcalendar`,
263
+ # `:search` and the rest of WebDAV and CalDAV — but a Path Item has fields
264
+ # for eight, and any other key makes the whole document invalid. A route
265
+ # on such a verb places nothing; an operation reached by no other route
266
+ # lands under x-permittable-controllers, as an unrouted one does.
247
267
  def route_targets(routes)
248
268
  return {} if routes.nil?
249
269
 
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 }
270
+ routes.select { |route| OPENAPI_VERBS.include?(verb_of(route)) }
271
+ .group_by { |route| [route[:controller].to_s, route[:action].to_s] }
272
+ .transform_values do |matching|
273
+ matching.map { |route| [route[:path], verb_of(route), route[:template]] }.uniq
274
+ end
252
275
  end
253
276
 
254
- # Places every operation and returns the slots it filled, in placement
255
- # order (controller, action, route), for assign_unique_operation_ids.
277
+ # The fixed fields of an OpenAPI 3.1 Path Item that hold an operation.
278
+ OPENAPI_VERBS = %w[get put post delete options head patch trace].freeze
279
+
280
+ # OpenAPI forbids two path templates that differ only in their variable
281
+ # names: `/{locale}` and `/{slug}` are one path to it. rails_routes keeps
282
+ # one route's own variants apart, but two routes can still spell one
283
+ # shape two ways — `scope "(:locale)"` makes it common, its root being
284
+ # `/{locale}` and its `get ":slug"` `/{slug}` — and so can a hand-built
285
+ # list. Routes are read in the order given, which for rails_routes is
286
+ # the order Rails tries them, so the route Rails reaches first wins:
287
+ #
288
+ # - The first spelling of a shape names its path. A later route of the
289
+ # shape under another verb is placed there too, since Rails picks the
290
+ # verb before it fills a segment (`POST /foo` reaches `post ":slug"`
291
+ # whatever GET route comes before it). The shared path names its
292
+ # variables after the first route, so the later one's own spelling
293
+ # rides along as `template:` and is published as `x-permittable-path`.
294
+ # - Under a verb an earlier route of the shape already answers, a later
295
+ # spelling is shadowed: Rails sends the URL to the earlier route, and
296
+ # reaches the later one only when that route's constraints reject the
297
+ # request (`scope "(:locale)", locale: /en|fr/` passes `/foo` on to
298
+ # `:slug`). It is not emitted as a second, forbidden path, nor dropped
299
+ # without a trace: it is listed under `x-permittable-shadows` on the
300
+ # slot it shares (note_shadows), and an operation left with no slot
301
+ # lands in x-permittable-controllers like any whose slot is taken.
302
+ # Every shadowed route is listed, reachable or not: request
303
+ # constraints and constraint objects can let a request past the
304
+ # earlier route, and the descriptors do not carry them.
305
+ #
306
+ # A second route with the SAME spelling is not a shape collision; it is
307
+ # the slot collision place_operations already settles. Returns the
308
+ # routes to place and { [path, verb] => shadowed routes }.
309
+ def collapse_shapes(routes)
310
+ return [routes, {}] if routes.nil?
311
+
312
+ spelling = {}
313
+ first_at = {}
314
+ shadows = {}
315
+ kept = routes.filter_map do |route|
316
+ path = route[:path].to_s
317
+ verb = verb_of(route)
318
+ shape = path.gsub(/\{\w+\}/, "{}")
319
+ shared = (spelling[shape] ||= path)
320
+ listed = Array(route[:shadows]).map { |alternate| shadow_entry(route, alternate) }
321
+ if (first_at[[shape, verb]] ||= path) == path
322
+ (shadows[[shared, verb]] ||= []).concat(listed) unless listed.empty?
323
+ shared == path ? route : route.merge(path: shared, template: path)
324
+ else
325
+ (shadows[[shared, verb]] ||= []).push(shadow_entry(route, path), *listed)
326
+ nil
327
+ end
328
+ end
329
+ [kept, shadows]
330
+ end
331
+
332
+ def shadow_entry(route, path)
333
+ { "path" => path, "controller" => route[:controller].to_s, "action" => route[:action].to_s }
334
+ end
335
+
336
+ # Lists, on the operation at each slot, the same-shaped routes it
337
+ # shadows (collapse_shapes) and, from rails_routes' `shadows:`, the
338
+ # variants of its own route that a constraint keeps reachable. A slot
339
+ # with no documented operation has nowhere to say it; the shadowed
340
+ # operation is not moved in, because the route set still sends that URL
341
+ # to the slot's own, undocumented route.
342
+ def note_shadows(paths, shadows)
343
+ shadows.each do |(path, verb), entries|
344
+ holder = paths[path]
345
+ next unless holder&.key?(verb)
346
+
347
+ # The same operation object may sit at other slots, so the note goes
348
+ # on a copy, as an operationId rename does.
349
+ holder[verb] = holder[verb].merge("x-permittable-shadows" => entries.uniq)
350
+ end
351
+ end
352
+
353
+ # Places every operation and returns the slots it filled, for
354
+ # assign_unique_operation_ids, which ranks them by what they are: the
355
+ # order they are returned in names nothing.
256
356
  def place_operations(controller, operations, targets, paths, unrouted)
257
357
  key = controller_key(controller) || controller.inspect
258
358
  operations.each_with_object([]) do |(action, operation), slots|
@@ -261,16 +361,21 @@ module Permittable
261
361
  # controller already claimed is not written over: the loser stays
262
362
  # visible under x-permittable-controllers, where an operation with no
263
363
  # route at all lands, rather than disappearing from the document.
364
+ # rails_routes already drops a route that an unconstrained one ahead
365
+ # of it answers (see drop_shadowed), so a claimed slot comes from a
366
+ # route behind a constrained one, from two route lists concatenated,
367
+ # or from hand-built descriptors.
264
368
  free = action == "*" ? [] : targets.fetch(owner, []).reject { |path, verb| paths.dig(path, verb) }
265
369
  if free.empty?
266
370
  holder = (unrouted[key] ||= {})
267
371
  slots << OperationSlot.new(holder, action, nil, operation["operationId"], owner) unless holder.key?(action)
268
372
  holder[action] = operation
269
373
  else
270
- free.each do |path, verb|
374
+ free.each do |path, verb, template|
271
375
  holder = (paths[path] ||= {})
272
- holder[verb] = with_path_parameters(operation, path)
273
- slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner)
376
+ placed = with_path_parameters(operation, path)
377
+ holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
378
+ slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
274
379
  end
275
380
  end
276
381
  end
@@ -290,12 +395,24 @@ module Permittable
290
395
  # known before any is renamed and all of them are reserved, so a suffix
291
396
  # can never take a name that is another operation's own id — that
292
397
  # 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.
398
+ # Within a colliding group the slot collision_order ranks first keeps the
399
+ # plain id. Another takes its verb (users_update_put) when that verb
400
+ # differs from the plain id's and the suffixed id is free; otherwise it
401
+ # is numbered in rank order (posts_create_2 for a second POST,
402
+ # users_update_2 for a second PATCH). The suffix names how the slot
403
+ # differs from the plain one, so a verb the two share would say nothing
404
+ # true.
405
+ #
406
+ # The rank is read off each slot's operation, path and verb, never off
407
+ # the order the slots were found in, and groups are visited in id order
408
+ # (so even a suffix two groups could both spell goes the same way every
409
+ # time). Reordering routes.rb or loading a controller earlier therefore
410
+ # renames nothing. What no scheme can avoid is a NEW collider: it takes a
411
+ # suffix or, ranking ahead, takes the plain id from the slot that held
412
+ # it, and every numbered slot ranked behind it moves along one. So
413
+ # adding, removing or renaming a colliding route, action or controller
414
+ # can still rename the other members of its group — never an id that
415
+ # stands alone.
299
416
  #
300
417
  # Unrouted operations take part: x-permittable-controllers is in the same
301
418
  # document and feeds the same generators. They yield the plain id to a
@@ -304,7 +421,7 @@ module Permittable
304
421
  slots = slots.select(&:id)
305
422
  taken = slots.to_set(&:id)
306
423
  next_number = Hash.new(2)
307
- slots.group_by(&:id).each_value do |group|
424
+ slots.group_by(&:id).sort_by(&:first).each do |_id, group|
308
425
  next if group.one?
309
426
 
310
427
  plain, *renamed = collision_order(group)
@@ -320,20 +437,39 @@ module Permittable
320
437
  end
321
438
  end
322
439
 
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.
440
+ # The rank within one colliding group. Routed before unrouted. Then by
441
+ # operation, its controller path and then its action compared as
442
+ # strings, so one operation's slots stay together and admin/users#index
443
+ # ranks ahead of admin_users#index whichever controller loaded first; a
444
+ # PATCH in one operation does not take the plain id from a PUT in
445
+ # another. Within one operation, the shallowest path first (fewest
446
+ # segments, then the path as a string), so `/posts` ranks ahead of
447
+ # `/{locale}/posts` and `/users/{id}` ahead of
448
+ # `/orgs/{org_id}/users/{id}`. Then the verb, in VERB_RANK order, which
449
+ # puts PATCH ahead of PUT: `match via: [:put, :patch]` lists PUT first
450
+ # where `resources` lists PATCH first, and without it one pair of routes
451
+ # would name the PATCH method two ways.
452
+ #
453
+ # Every key is a property of the slot itself, and no two slots share all
454
+ # of them (an operation fills a path+verb slot once, and has at most one
455
+ # unrouted entry), so the rank never falls back on the order the slots
456
+ # arrived in. That order (controller load order, then route order) is
457
+ # what used to decide, and why reordering routes.rb could rename a
458
+ # generated client method.
329
459
  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)
460
+ group.sort_by do |slot|
461
+ path = slot.path.to_s
462
+ [slot.verb ? 0 : 1, slot.owner, path.count("/"), path, verb_rank(slot.verb)]
463
+ end
464
+ end
465
+
466
+ # The order rails_routes expands `via: :all` into, with PATCH moved ahead
467
+ # of PUT (see collision_order). A verb a hand-built descriptor names
468
+ # beyond these ranks after them, alphabetically.
469
+ VERB_RANK = %w[get post patch put delete].freeze
470
+
471
+ def verb_rank(verb)
472
+ [VERB_RANK.index(verb) || VERB_RANK.length, verb.to_s]
337
473
  end
338
474
 
339
475
  # The next free "#{id}_n", n from 2. The counter per id means no number
@@ -369,6 +505,85 @@ module Permittable
369
505
  end
370
506
  end
371
507
 
508
+ # Drops every descriptor for a verb and concrete path that an earlier,
509
+ # unconstrained route already answers (rails_routes calls it last).
510
+ # Journey sorts the routes matching a request by precedence, their
511
+ # position in the route set, and dispatches to the first. So with
512
+ # `get "hooks", to: "webhooks#index"` drawn ahead of
513
+ # `match "hooks", to: "webhooks#receive", via: :all`, GET /hooks is
514
+ # index's and receive answers only the other four verbs; drawn the other
515
+ # way round, index is never reached at all. Describing the later route
516
+ # anyway put a phantom `GET /hooks receive` row in the audit, and let the
517
+ # export hand that slot to whichever of the two operations it placed
518
+ # first. Both read their routes from rails_routes, so dropping it there
519
+ # keeps them in agreement with each other and with Rails. A route drawn
520
+ # twice folds into one the same way.
521
+ #
522
+ # Only a route that answers EVERY request for its path and verb hides
523
+ # the ones behind it (see unconditional?). A constrained route answers
524
+ # what its constraint lets through and passes the rest on, so with
525
+ # `constraints(AdminConstraint) { post "settings", ... }` ahead of
526
+ # `post "settings", to: "settings#update"`, every non-admin POST reaches
527
+ # settings#update. The audit is a gate, and a hidden route there is a
528
+ # false pass, so the conservative rule errs towards listing: a
529
+ # constrained route is described as before but never drops anything,
530
+ # even when its constraint happens to admit everything. In the export
531
+ # both routes may then claim one slot, and the second takes the existing
532
+ # fallback (see place_operations).
533
+ #
534
+ # Descriptors arrive in route order (then verb, then optional variant),
535
+ # so the first one seen is the first route. Precedence is a property of
536
+ # ONE route set, which is why this runs inside each rails_routes call and
537
+ # not in the readers: an engine's routes are relative to its mount point,
538
+ # and a list concatenated from an app's and an engine's must not let one
539
+ # shadow the other.
540
+ #
541
+ # Two more limits, both towards listing more. Only identical templates
542
+ # are compared, so templates that differ at all, even only in a
543
+ # variable's name, are both kept. And only controller routes shadow: a
544
+ # redirect or a Rack endpoint is not described at all, so a controller
545
+ # route behind one on the same path is still listed.
546
+ #
547
+ # `unconditional:` is this method's input only; it is stripped from what
548
+ # it returns, so callers see the descriptor shape they always have. (It
549
+ # is not `shadows:`, which names the same-shaped variants a kept path
550
+ # stands in for and does reach the exporter; see optional_variants.)
551
+ def drop_shadowed(descriptors)
552
+ answered = Set.new
553
+ descriptors.each_with_object([]) do |descriptor, kept|
554
+ slot = descriptor.values_at(:verb, :path)
555
+ next if answered.include?(slot)
556
+
557
+ answered << slot if descriptor[:unconditional]
558
+ kept << descriptor.except(:unconditional)
559
+ end
560
+ end
561
+
562
+ # Rails's own non-greedy requirement on a glob segment (`*rest`), /.+?/
563
+ # on 6.1 and /.+?/m from 7.0. It narrows nothing.
564
+ GLOB_DEFAULTS = [/.+?/, /.+?/m].freeze
565
+
566
+ # Whether a route answers its path and verb for every request, so that no
567
+ # later route with the same template can be reached for them. Rails
568
+ # records a constraint in one of three places on the Journey route, the
569
+ # same on 6.1 and 8.1: a segment requirement (`id: /\d+/`, a
570
+ # `scope "(:locale)", locale: /en|fr/`) in `path.requirements`; a request
571
+ # constraint (subdomain, host) in `constraints`; and a constraint object
572
+ # or lambda, inline or from a `constraints(...)` block, as an app wrapped
573
+ # in Mapper::Constraints whose list is not empty. `defaults:` are not
574
+ # constraints and land in none of these. The glob default is not counted
575
+ # either (Journey::Route#requirements drops it the same way). A route
576
+ # that cannot answer these questions, such as a hand-built duck, counts
577
+ # as constrained: unsure, it hides nothing.
578
+ def unconditional?(route)
579
+ return false unless route.respond_to?(:constraints) && route.respond_to?(:app) &&
580
+ route.path.respond_to?(:requirements)
581
+
582
+ wrapped = route.app.respond_to?(:constraints) ? route.app.constraints : []
583
+ route.constraints.empty? && wrapped.empty? &&
584
+ route.path.requirements.each_value.all? { |requirement| GLOB_DEFAULTS.include?(requirement) }
585
+ end
586
+
372
587
  # What a `via: :all` route answers, in the "|"-joined form Journey uses
373
588
  # for a route with several verbs.
374
589
  ALL_VERBS = "GET|POST|PUT|PATCH|DELETE".freeze
@@ -384,12 +599,23 @@ module Permittable
384
599
  # parentheses are not valid in an OpenAPI path template, so leaving
385
600
  # `scope "(:locale)"` as `(/{locale})/posts` made the whole document fail
386
601
  # validation. Each variant is its own descriptor, so each path's variables
387
- # are required there — which, for that path, they are.
602
+ # are required there — which, for that path, they are. The variants are
603
+ # worked out once per route, before the verb split: they are the same
604
+ # for every verb the route answers.
388
605
  #
389
606
  # Each descriptor also carries `route:`, the index of the route it came
390
607
  # from, so a reader counting routes rather than paths (Audit.summary) can
391
608
  # tell one route's expanded variants from a second route that happens to
392
609
  # reach the same action. The exporter ignores it.
610
+ #
611
+ # A variant that stands in for a same-shaped variant a constraint keeps
612
+ # reachable (see optional_variants) also carries `shadows:`, those
613
+ # variants' paths, which the exporter lists on the operation. Most
614
+ # routes have none, and then the key is left out.
615
+ #
616
+ # A verb on a path that an earlier, unconstrained route already answers
617
+ # is left out: Rails never dispatches it to the later route (see
618
+ # drop_shadowed).
393
619
  def rails_routes(app)
394
620
  descriptors = app.routes.routes.each_with_index.flat_map do |route, index|
395
621
  requirements = route.requirements
@@ -401,60 +627,223 @@ module Permittable
401
627
  # audit entirely. Expand it into the verbs it actually answers.
402
628
  verb = ALL_VERBS if verb.empty?
403
629
 
404
- path = route.path.spec.to_s.sub("(.:format)", "").gsub(/[:*](\w+)/) { "{#{Regexp.last_match(1)}}" }
630
+ variants = optional_variants(route.path.spec, requirements)
631
+ # Read once per route; drop_shadowed reads it, then strips it.
632
+ unconditional = unconditional?(route)
405
633
  # One route can answer several verbs (`match via: [:patch, :put]`, and
406
634
  # the PATCH|PUT pair resources generates); documenting only the first
407
- # dropped the others from the export entirely.
408
- verb.split("|").map do |single|
409
- { controller: requirements[:controller], action: requirements[:action],
410
- verb: single.downcase, path: path, route: index }
635
+ # dropped the others from the export entirely. Verb by verb, then
636
+ # variant by variant: drop_shadowed takes the first descriptor it
637
+ # sees for a path and verb as the route Rails dispatches it to.
638
+ verb.split("|").flat_map do |single|
639
+ variants.map do |path, shadows|
640
+ descriptor = { controller: requirements[:controller], action: requirements[:action],
641
+ verb: single.downcase, path: path, route: index, unconditional: unconditional }
642
+ shadows.empty? ? descriptor : descriptor.merge(shadows: shadows)
643
+ end
644
+ end
645
+ end
646
+ drop_shadowed(descriptors)
647
+ end
648
+
649
+ # A route parameter as optional_variants reads it: a `:name` segment or a
650
+ # `*name` wildcard (`star`), with the regexp the route constrains it to,
651
+ # if any.
652
+ Param = Struct.new(:name, :constraint, :star) do
653
+ # Journey's defaults: a segment stops at "/", "." and "?"; a wildcard
654
+ # takes anything.
655
+ def pattern
656
+ constraint || (star ? /.+/m : %r{[^./?]+})
657
+ end
658
+ end
659
+
660
+ # Journey's separators. A parameter never spans one, so variants are
661
+ # compared token by token between them.
662
+ SEPARATORS = %w[/ .].freeze
663
+
664
+ # The optional group Rails appends to every route unless told otherwise.
665
+ FORMAT_GROUP = "(.:format)".freeze
666
+
667
+ # Every concrete path a route stands for, each mapped to the paths of the
668
+ # same-shaped variants it stands in for (usually none):
669
+ # `archive(/:year(/:month))` → `/archive`, `/archive/{year}`,
670
+ # `/archive/{year}/{month}`.
671
+ #
672
+ # Rails matches a URL against the route's regexp, which tries each group
673
+ # present before absent, left to right, and binds the URL to the first
674
+ # variant whose segments accept it. Variants are enumerated in that
675
+ # order, and one is dropped when an earlier variant accepts every URL it
676
+ # would (shadowed?). So `x(/:a)(/:b)` drops `/x/{b}` (`/x/foo` is :a),
677
+ # and `x(/:a)(/new)` drops `/x/new`, which Rails reads as :a = "new".
678
+ # Constraints count: with `a: /\d+/`, "new" fails :a, so there `/x/new`
679
+ # is a real URL and stays.
680
+ #
681
+ # The same constraint makes `/x/foo` :b in `x(/:a)(/:b)`, so `/x/{b}` is
682
+ # real beside `/x/{a}`. OpenAPI forbids two templates that differ only in
683
+ # variable names, so the one Rails tries first is kept and the other is
684
+ # named in its shadows rather than emitted. Distinct shapes are both
685
+ # kept; OpenAPI allows `/x/new` beside `/x/{a}`.
686
+ #
687
+ # Before any of that, a variant that is no request path of its own is
688
+ # dropped (request_path?). An absent group at the start of a route
689
+ # leaves them: `/(:locale)(/:theme)` without :locale is `//{theme}`,
690
+ # but Rails squeezes `GET //dark` to `GET /dark`, which binds :locale
691
+ # (or, constrained away from it, no route at all); and
692
+ # `(/:locale(/:region))` with nothing present is empty, so the route
693
+ # does not answer `GET /`.
694
+ #
695
+ # The list is then reversed, which puts every group's ABSENT variant
696
+ # first at every nesting level, the order the document lists them in.
697
+ # The variants of one route share an operation, and it is the operationId
698
+ # dedupe (assign_unique_operation_ids), not this expansion, that makes
699
+ # their ids unique. It ranks the shallowest path first whatever order the
700
+ # variants arrive in, so `/posts` keeps `posts_create` and
701
+ # `/{locale}/posts` takes the suffix.
702
+ #
703
+ # The spec is walked as the tree Journey parsed, node by node, not as its
704
+ # string: `to_s` prints an escaped parenthesis in a literal as a bare
705
+ # one, which a string scan then read as a group. A Journey-shaped stand-in
706
+ # may carry a String spec instead (string_variants).
707
+ def optional_variants(spec, requirements = {})
708
+ variants = if spec.respond_to?(:type)
709
+ tree_variants(spec, requirements)
710
+ else
711
+ string_variants(spec.to_s.sub(FORMAT_GROUP, ""), requirements).first
712
+ end
713
+ variants = variants.map { |tokens| join_literals(tokens) }
714
+ # No group, no choice: nothing for one variant to shadow.
715
+ return { render(variants.first) => [] } if variants.one?
716
+
717
+ kept = {}
718
+ first_of_shape = {}
719
+ # Such a variant matches no request, so it shadows nothing either.
720
+ variants = variants.select { |tokens| request_path?(tokens) }
721
+ variants.each_with_index do |tokens, index|
722
+ next if variants.take(index).any? { |earlier| shadowed?(earlier, tokens) }
723
+
724
+ path = render(tokens)
725
+ twin = (first_of_shape[path.gsub(/\{\w+\}/, "{}")] ||= path)
726
+ if twin == path
727
+ kept[path] ||= []
728
+ else
729
+ kept[twin] << path
411
730
  end
412
731
  end
413
- descriptors.flat_map do |descriptor|
414
- optional_variants(descriptor[:path]).map { |variant| descriptor.merge(path: variant) }
732
+ kept.to_a.reverse.to_h
733
+ end
734
+
735
+ # A Journey tree's variants, each a list of tokens: literal text and
736
+ # Param. A group yields its contents' variants, then the empty one —
737
+ # present before absent, as Rails' regexp tries them — except the
738
+ # `(.:format)` group, which is left out. The node types are the same from
739
+ # Rails 6.1 to 8.1; OR is not reachable through the routing DSL (which
740
+ # escapes "|") but is read as its alternatives, in order.
741
+ def tree_variants(node, requirements)
742
+ case node.type
743
+ when :CAT
744
+ tree_variants(node.left, requirements).product(tree_variants(node.right, requirements))
745
+ .map { |left, right| left + right }
746
+ when :GROUP
747
+ node.to_s == FORMAT_GROUP ? [[]] : tree_variants(node.left, requirements) + [[]]
748
+ when :OR then node.children.flat_map { |child| tree_variants(child, requirements) }
749
+ when :SYMBOL then [[param(node.name, false, requirements)]]
750
+ when :STAR then [[param(node.name, true, requirements)]]
751
+ else [[node.left.to_s]] # LITERAL, SLASH, DOT
415
752
  end
416
753
  end
417
754
 
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 == ")"
755
+ # tree_variants for a String spec, from `pos` to the `)` closing the
756
+ # group at `depth` (or the end); returns the variants and the position
757
+ # after them. Journey's escapes hold: `\(`, `\)` and `\:` are literal
758
+ # text. A parenthesis with no partner raises: the old scan stopped at an
759
+ # unmatched `)` and returned what it had read, so `/un)matched` was
760
+ # documented as `/un` without a word.
761
+ def string_variants(spec, requirements, pos = 0, depth = 0)
762
+ variants = [[]]
763
+ while pos < spec.length
764
+ case (char = spec[pos])
765
+ when "("
766
+ inner, pos = string_variants(spec, requirements, pos + 1, depth + 1)
767
+ variants = variants.product(inner + [[]]).map { |left, right| left + right }
768
+ next
769
+ when ")"
770
+ raise ArgumentError, "unbalanced \")\" in route path #{spec.inspect}" if depth.zero?
771
+
449
772
  return [variants, pos + 1]
450
- else
451
- variants.each { |variant| variant << char }
773
+ when "\\"
452
774
  pos += 1
775
+ token = spec[pos].to_s
776
+ when ":", "*"
777
+ name = spec[(pos + 1)..][/\A\w+/]
778
+ token = name ? param(name, char == "*", requirements) : char
779
+ pos += name.length if name
780
+ else
781
+ token = char
453
782
  end
783
+ variants.each { |variant| variant << token }
784
+ pos += 1
454
785
  end
786
+ raise ArgumentError, "unbalanced \"(\" in route path #{spec.inspect}" unless depth.zero?
787
+
455
788
  [variants, pos]
456
789
  end
457
790
 
791
+ def param(name, star, requirements)
792
+ constraint = requirements[name.to_sym]
793
+ Param.new(name, constraint.is_a?(Regexp) ? constraint : nil, star)
794
+ end
795
+
796
+ # Adjacent literal text joined into one token per run between
797
+ # separators, so two variants line up token by token whichever nodes or
798
+ # characters their text came from.
799
+ def join_literals(tokens)
800
+ tokens.each_with_object([]) do |token, joined|
801
+ next if token == ""
802
+
803
+ if [token, joined.last].all? { |text| text.is_a?(String) && !SEPARATORS.include?(text) }
804
+ joined[-1] = joined.last + token
805
+ else
806
+ joined << token
807
+ end
808
+ end
809
+ end
810
+
811
+ def render(tokens)
812
+ path = tokens.map { |token| token.is_a?(Param) ? "{#{token.name}}" : token }.join
813
+ path.empty? ? "/" : path
814
+ end
815
+
816
+ # Whether a variant could be a request's path. Rails normalizes the
817
+ # path before it matches any route (Journey::Router::Utils.
818
+ # normalize_path, from 6.1 to 8.1): it is given a leading "/", and every
819
+ # run of slashes is squeezed to one. So a variant that does not start
820
+ # with "/", or that holds two in a row, is matched by no request: the
821
+ # URL a client would build from it reaches the router as another path.
822
+ def request_path?(tokens)
823
+ tokens.first == "/" && tokens.each_cons(2).none? { |pair| pair == %w[/ /] }
824
+ end
825
+
826
+ # Whether Rails, trying `earlier` first, leaves `later` no URL: wherever
827
+ # `later` has a token, `earlier` accepts everything it does. Text must be
828
+ # equal; a parameter accepts text its pattern matches whole, and another
829
+ # parameter when it is itself unconstrained (a segment never takes a
830
+ # wildcard's slashes) or constrained by the same regexp. Two different
831
+ # constraints are taken to leave `later` reachable, which keeps a
832
+ # variant rather than dropping a real one.
833
+ def shadowed?(earlier, later)
834
+ earlier.length == later.length && earlier.zip(later).all? { |mine, theirs| accepts?(mine, theirs) }
835
+ end
836
+
837
+ def accepts?(mine, theirs)
838
+ return mine == theirs unless mine.is_a?(Param)
839
+
840
+ pattern = mine.pattern
841
+ return Regexp.new("\\A(?:#{pattern.source})\\z", pattern.options).match?(theirs) if theirs.is_a?(String)
842
+ return mine.constraint == theirs.constraint if mine.constraint
843
+
844
+ mine.star || !theirs.star
845
+ end
846
+
458
847
  def controller_key(controller)
459
848
  return controller.controller_path if controller.respond_to?(:controller_path)
460
849