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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +79 -18
- data/lib/permittable/audit.rb +231 -20
- data/lib/permittable/authored_values.rb +24 -8
- data/lib/permittable/generator.rb +69 -14
- data/lib/permittable/json_schema.rb +28 -4
- data/lib/permittable/open_api.rb +438 -80
- data/lib/permittable/tasks/generate.rake +9 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +214 -49
- metadata +2 -2
data/lib/permittable/open_api.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
230
|
-
# `holder[field]` is the placed operation; `id` its natural
|
|
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
|
|
235
|
-
# once, with each verb normalised once: matching every
|
|
236
|
-
# every route made placement quadratic in the size of
|
|
237
|
-
# route declared twice names one slot, so it is kept
|
|
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
|
|
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
|
-
#
|
|
247
|
-
#
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
286
|
-
# takes its verb (users_update_put) when that verb
|
|
287
|
-
# id's and the suffixed id is free; otherwise it
|
|
288
|
-
#
|
|
289
|
-
# The suffix names how the slot
|
|
290
|
-
# the two share would say nothing
|
|
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).
|
|
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
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
#
|
|
320
|
-
#
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
406
|
-
|
|
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
|
-
#
|
|
411
|
-
#
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
#
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
#
|
|
418
|
-
#
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
#
|
|
422
|
-
#
|
|
423
|
-
# the
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
|