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