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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +435 -32
- data/lib/permittable/audit.rb +241 -24
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +335 -66
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +470 -81
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +11 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +367 -69
- 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
|
|
|
@@ -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
|
-
|
|
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
|
|
238
|
-
# `holder[field]` is the placed operation; `id` its natural
|
|
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
|
|
243
|
-
# once, with each verb normalised once: matching every
|
|
244
|
-
# every route made placement quadratic in the size of
|
|
245
|
-
# route declared twice names one slot, so it is kept
|
|
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.
|
|
251
|
-
.
|
|
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
|
-
#
|
|
255
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
|
294
|
-
# takes its verb (users_update_put) when that verb
|
|
295
|
-
# id's and the suffixed id is free; otherwise it
|
|
296
|
-
#
|
|
297
|
-
# The suffix names how the slot
|
|
298
|
-
# the two share would say nothing
|
|
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).
|
|
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
|
-
#
|
|
324
|
-
#
|
|
325
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
#
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
#
|
|
422
|
-
#
|
|
423
|
-
#
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
|