permittable 0.11.1 → 0.13.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 +50 -0
- data/README.md +38 -20
- data/lib/permittable/audit.rb +16 -0
- data/lib/permittable/authored_values.rb +17 -3
- data/lib/permittable/column_guard.rb +29 -0
- data/lib/permittable/contract.rb +4 -1
- data/lib/permittable/error_envelope.rb +43 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +40 -1
- data/lib/permittable/json_schema.rb +14 -2
- data/lib/permittable/open_api.rb +222 -25
- data/lib/permittable/rspec.rb +152 -29
- data/lib/permittable/tasks/audit.rake +3 -5
- data/lib/permittable/tasks/generate.rake +1 -4
- data/lib/permittable/tasks/openapi.rake +1 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +388 -68
- metadata +2 -2
|
@@ -25,7 +25,8 @@ module Permittable
|
|
|
25
25
|
# use UserFields
|
|
26
26
|
# end
|
|
27
27
|
#
|
|
28
|
-
# # PATCH: same fields, nothing mandatory
|
|
28
|
+
# # PATCH: same fields, nothing mandatory, no defaults (a default
|
|
29
|
+
# # would reset plan to "free" on every PATCH that omits it).
|
|
29
30
|
# permit_params :update, root: :user, model: User do
|
|
30
31
|
# use UserFields, optional: true
|
|
31
32
|
# end
|
|
@@ -43,7 +43,7 @@ module Permittable
|
|
|
43
43
|
# lists the column. They ride on the column, set by in_rule, so the scan
|
|
44
44
|
# path hands them to column_line without its own methods changing.
|
|
45
45
|
DraftColumn = Struct.new(:name, :type, :null, :default, :default_function, :enum, :enum_integers, :sensitive,
|
|
46
|
-
:unread, :rule, :listed, :assigned, keyword_init: true) do
|
|
46
|
+
:unread, :rule, :listed, :assigned, :array, :array_default, keyword_init: true) do
|
|
47
47
|
def in_rule(rule, listed: false)
|
|
48
48
|
self.class.new(**to_h, rule: rule, listed: listed)
|
|
49
49
|
end
|
|
@@ -554,6 +554,8 @@ module Permittable
|
|
|
554
554
|
def draft_column(model, column)
|
|
555
555
|
DraftColumn.new(name: column.name, type: column.type, null: column.null,
|
|
556
556
|
default_function: column.respond_to?(:default_function) && column.default_function,
|
|
557
|
+
array: column.respond_to?(:array?) && column.array? == true,
|
|
558
|
+
array_default: array_default?(column.default),
|
|
557
559
|
sensitive: sensitive_role(model, column.name), assigned: assigned?(model, column),
|
|
558
560
|
**model_facts(model, column))
|
|
559
561
|
end
|
|
@@ -1084,9 +1086,11 @@ module Permittable
|
|
|
1084
1086
|
# does not parse.
|
|
1085
1087
|
def column_line(column)
|
|
1086
1088
|
return "# TODO: #{column.name} #{omitted_note(column)}" if column.sensitive && !column.listed
|
|
1089
|
+
return array_column_line(column) if column.array
|
|
1087
1090
|
|
|
1088
1091
|
type = column.enum ? :string : COLUMN_TYPES[column.type]
|
|
1089
1092
|
return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
|
|
1093
|
+
return json_array_todo(column) if type == :json && column.array_default
|
|
1090
1094
|
|
|
1091
1095
|
required = column.rule != :update && required_column?(column)
|
|
1092
1096
|
line = "#{required ? 'required' : 'optional'} #{column.name.to_sym.inspect}, :#{type}"
|
|
@@ -1095,6 +1099,41 @@ module Permittable
|
|
|
1095
1099
|
notes.empty? ? line : "#{line} # #{notes.join('; ')}"
|
|
1096
1100
|
end
|
|
1097
1101
|
|
|
1102
|
+
# A PostgreSQL array column (`t.string :tags, array: true`) reports its
|
|
1103
|
+
# ELEMENT type as `type` and says it is an array through `array?`. Drafted
|
|
1104
|
+
# as a scalar, it would reject as invalid_type the very array it stores
|
|
1105
|
+
# the moment the draft is enforced. `of:` takes only a scalar type, so a
|
|
1106
|
+
# json/jsonb/hstore element type (an array of documents) is left as a
|
|
1107
|
+
# TODO, as is any element type with no contract type at all.
|
|
1108
|
+
def array_column_line(column)
|
|
1109
|
+
type = COLUMN_TYPES[column.type]
|
|
1110
|
+
unless type && type != :json
|
|
1111
|
+
return "# TODO: #{column.name} (#{column.type}[]) has no contract type — declare it as a nested block or an array"
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
required = column.rule != :update && required_column?(column)
|
|
1115
|
+
line = "array #{column.name.to_sym.inspect}, of: :#{type}#{', required: true' if required}"
|
|
1116
|
+
notes = [column.default, *column_todos(column), "TODO: declare length: — an array without one is unbounded"]
|
|
1117
|
+
"#{line} # #{notes.compact.join('; ')}"
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# :json accepts only an object, but a json/jsonb column can as well hold
|
|
1121
|
+
# an array — and when its default is one, it does: drafted as :json, it
|
|
1122
|
+
# rejected as invalid_type, once enforced, what every client sends for
|
|
1123
|
+
# it. Whether the elements are scalars or objects the column cannot say,
|
|
1124
|
+
# so it is a TODO naming both declarations rather than a guess.
|
|
1125
|
+
def json_array_todo(column)
|
|
1126
|
+
name = column.name.to_sym.inspect
|
|
1127
|
+
"# TODO: #{column.name} (#{column.type}) defaults to an array, which :json refuses — declare it " \
|
|
1128
|
+
"`array #{name}, of: :string` (confirm the element type) or `array #{name} do ... end` for objects"
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
# A database default as the adapter reports it: the SQL literal "[]"
|
|
1132
|
+
# through Rails 8.0, possibly the cast Array after.
|
|
1133
|
+
def array_default?(default)
|
|
1134
|
+
default.is_a?(Array) || (default.is_a?(String) && default.lstrip.start_with?("["))
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1098
1137
|
# Drafting from columns alone, a sensitive column is left out of the
|
|
1099
1138
|
# fields — nothing says any client sends it — and named here instead.
|
|
1100
1139
|
# The lock_version note says where to declare it in the rules actually
|
|
@@ -391,16 +391,28 @@ module Permittable
|
|
|
391
391
|
# length: reasons about characters on strings and element count on
|
|
392
392
|
# arrays; either way it is an exact Integer or a Range (possibly endless
|
|
393
393
|
# / beginless, possibly exclusive).
|
|
394
|
+
# An infinite end (`1..Float::INFINITY`) is no bound, as for numeric
|
|
395
|
+
# `in:` — and JSON has no Infinity, so exporting it raised.
|
|
394
396
|
def length_bounds(spec)
|
|
395
397
|
case spec
|
|
396
398
|
when Integer then [spec, spec]
|
|
397
399
|
when Range
|
|
398
|
-
|
|
399
|
-
|
|
400
|
+
min = finite_length(spec.begin)
|
|
401
|
+
max = finite_length(spec.end)
|
|
402
|
+
max -= 1 if max && spec.exclude_end?
|
|
403
|
+
[min, max]
|
|
400
404
|
else [nil, nil]
|
|
401
405
|
end
|
|
402
406
|
end
|
|
403
407
|
|
|
408
|
+
# A whole-number Float/Rational end (`1..10.0`) is published as the
|
|
409
|
+
# Integer it means: minLength and friends are counts.
|
|
410
|
+
def finite_length(value)
|
|
411
|
+
return nil if value.nil? || (value.is_a?(Float) && value.infinite?)
|
|
412
|
+
|
|
413
|
+
value.to_i
|
|
414
|
+
end
|
|
415
|
+
|
|
404
416
|
# Documentation keys shared by every field kind. `default:`/`example:`
|
|
405
417
|
# are stored as the contract casts them (Date, Time, BigDecimal), so they
|
|
406
418
|
# are re-encoded as JSON scalars.
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -373,7 +373,7 @@ module Permittable
|
|
|
373
373
|
else
|
|
374
374
|
free.each do |path, verb, template|
|
|
375
375
|
holder = (paths[path] ||= {})
|
|
376
|
-
placed = with_path_parameters(operation, path)
|
|
376
|
+
placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path)
|
|
377
377
|
holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
|
|
378
378
|
slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
|
|
379
379
|
end
|
|
@@ -488,23 +488,93 @@ module Permittable
|
|
|
488
488
|
# OpenAPI 3.1 requires every variable in a path template to be declared as
|
|
489
489
|
# a path parameter — a document templating {id} without declaring it is
|
|
490
490
|
# invalid, which every member route produced. The route set does not say
|
|
491
|
-
# what an :id is
|
|
492
|
-
# a
|
|
493
|
-
|
|
494
|
-
|
|
491
|
+
# what an :id is, so a variable is a string unless the contract says
|
|
492
|
+
# otherwise: a rootless contract field named like one is that parameter's
|
|
493
|
+
# schema (path_fields), and leaves the body schema.
|
|
494
|
+
#
|
|
495
|
+
# `own` is the route's own spelling of the template, which differs
|
|
496
|
+
# from `path` when collapse_shapes placed the route under another
|
|
497
|
+
# route's spelling: the same shape, so the names pair up by position.
|
|
498
|
+
def with_path_parameters(operation, path, rule, own = path)
|
|
499
|
+
# A name the path templates twice (a mount and an engine route both
|
|
500
|
+
# name `:id`) is declared once: OpenAPI forbids two parameters with
|
|
501
|
+
# one name and location.
|
|
502
|
+
variables = path.scan(/\{(\w+)\}/).flatten.uniq
|
|
495
503
|
return operation if variables.empty?
|
|
496
504
|
|
|
505
|
+
own_names = own.scan(/\{(\w+)\}/).flatten
|
|
506
|
+
fields = path_fields(rule, path.scan(/\{(\w+)\}/).flatten.zip(own_names).uniq(&:first))
|
|
497
507
|
parameters = variables.map do |name|
|
|
498
|
-
|
|
508
|
+
schema = fields[name] ? path_schema(fields[name]) : { "type" => "string" }
|
|
509
|
+
{ "name" => name, "in" => "path", "required" => true, "schema" => schema }
|
|
499
510
|
end
|
|
500
511
|
# Inserted ahead of requestBody, where a reader of the document expects
|
|
501
512
|
# it; emission stays deterministic either way.
|
|
502
513
|
operation.each_with_object({}) do |(key, value), out|
|
|
503
|
-
|
|
504
|
-
|
|
514
|
+
if key == "requestBody"
|
|
515
|
+
out["parameters"] = parameters
|
|
516
|
+
value = body_without(rule, fields.values, own_names)
|
|
517
|
+
end
|
|
518
|
+
out[key] = value unless value.nil?
|
|
505
519
|
end
|
|
506
520
|
end
|
|
507
521
|
|
|
522
|
+
# { path variable => the contract's field for it }. Rails merges the
|
|
523
|
+
# route's path parameters over the request's, so a field the route fills
|
|
524
|
+
# from the URL is read from the URL, never from the body: `id` in the
|
|
525
|
+
# body is overwritten, and a request that leaves it out is fine. A rooted
|
|
526
|
+
# contract reads its fields from inside the root, which a path parameter
|
|
527
|
+
# never reaches, so only a rootless one has such fields, and only at its
|
|
528
|
+
# top level. `names` pairs each variable of the placed path with the
|
|
529
|
+
# route's own name for it.
|
|
530
|
+
#
|
|
531
|
+
# Only a scalar field moves. A URL segment is one string, so an array or
|
|
532
|
+
# a hash can never be what it carries (`POST /defs/1,2` against
|
|
533
|
+
# `array :id` is a 422). Such a field stays in the body, where a client
|
|
534
|
+
# can at least say what it means.
|
|
535
|
+
def path_fields(rule, names)
|
|
536
|
+
return {} if rule[:root]
|
|
537
|
+
|
|
538
|
+
names.filter_map do |variable, own|
|
|
539
|
+
field = rule[:fields].find { |f| f[:name].to_s == own }
|
|
540
|
+
[variable, field] if field && field[:kind] == :scalar
|
|
541
|
+
end.to_h
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# A moved field's schema as the path parameter documents it. The URL
|
|
545
|
+
# always supplies the segment, so a `default:` never applies and the
|
|
546
|
+
# value is never null: `nullable:` would only add "null" to the type.
|
|
547
|
+
def path_schema(field)
|
|
548
|
+
JsonSchema.field(field.except(:default, :nullable))
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# The requestBody for `rule` without the fields a path parameter took
|
|
552
|
+
# over; nil, to leave it out, when that was every field. Such a body
|
|
553
|
+
# would be an empty object. An action declared with no fields at all
|
|
554
|
+
# keeps the empty body it always had.
|
|
555
|
+
#
|
|
556
|
+
# `unknown: :error` forbids every key the contract does not declare, but
|
|
557
|
+
# the runtime exempts the route's path parameters from that check and
|
|
558
|
+
# then overwrites them with the URL's values: `PATCH /widgets/7` with
|
|
559
|
+
# {"id":7,"name":"x"} is a 200 whether or not the contract declares `id`.
|
|
560
|
+
# That is the payload a Rails form, or a client sending a record back,
|
|
561
|
+
# produces, so the schema must not forbid it. Each name the route
|
|
562
|
+
# templates therefore stays in the body as an unconstrained property,
|
|
563
|
+
# any value and never required, since the value is read from the URL and
|
|
564
|
+
# nothing in the body can make the request wrong. A rooted contract
|
|
565
|
+
# needs none of this: its wrapper object is permissive already.
|
|
566
|
+
def body_without(rule, moved, templated)
|
|
567
|
+
rest = rule[:fields] - moved
|
|
568
|
+
return if rest.empty? && !moved.empty?
|
|
569
|
+
|
|
570
|
+
body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
|
|
571
|
+
return body unless rule[:unknown] == :error && !rule[:root]
|
|
572
|
+
|
|
573
|
+
properties = body["content"]["application/json"]["schema"]["properties"]
|
|
574
|
+
templated.each { |name| properties[name] ||= {} }
|
|
575
|
+
body
|
|
576
|
+
end
|
|
577
|
+
|
|
508
578
|
# Drops every descriptor for a verb and concrete path that an earlier,
|
|
509
579
|
# unconstrained route already answers (rails_routes calls it last).
|
|
510
580
|
# Journey sorts the routes matching a request by precedence, their
|
|
@@ -533,10 +603,13 @@ module Permittable
|
|
|
533
603
|
#
|
|
534
604
|
# Descriptors arrive in route order (then verb, then optional variant),
|
|
535
605
|
# so the first one seen is the first route. Precedence is a property of
|
|
536
|
-
# ONE route
|
|
537
|
-
# not in the readers:
|
|
538
|
-
#
|
|
539
|
-
#
|
|
606
|
+
# ONE route table, which is why this runs once inside each rails_routes
|
|
607
|
+
# call and not in the readers: a list concatenated from an app's and an
|
|
608
|
+
# engine's must not let one shadow the other. An engine followed from its
|
|
609
|
+
# mount is part of that one table, expanded where the mount is drawn, so
|
|
610
|
+
# an app route ahead of the mount hides the engine's route for the same
|
|
611
|
+
# slot and an engine route hides a later app one (see rails_routes). An
|
|
612
|
+
# engine route is unconditional only when its mount is as well.
|
|
540
613
|
#
|
|
541
614
|
# Two more limits, both towards listing more. Only identical templates
|
|
542
615
|
# are compared, so templates that differ at all, even only in a
|
|
@@ -616,9 +689,77 @@ module Permittable
|
|
|
616
689
|
# A verb on a path that an earlier, unconstrained route already answers
|
|
617
690
|
# is left out: Rails never dispatches it to the later route (see
|
|
618
691
|
# drop_shadowed).
|
|
619
|
-
|
|
620
|
-
|
|
692
|
+
#
|
|
693
|
+
# A mounted Rails engine is part of the application: `mount Billing::Engine
|
|
694
|
+
# => "/billing"` makes every route the engine draws a URL of the app, so
|
|
695
|
+
# the audit's gate and the export must see them. With `engines: true`
|
|
696
|
+
# (the default) each such mount is followed into the engine's own route
|
|
697
|
+
# set, and the mount's path is put in front of every path found there,
|
|
698
|
+
# token by token, so a mount under `scope "(:locale)"`, one at a
|
|
699
|
+
# dynamic `at: ":tenant"` and one at "/" expand and join exactly as an
|
|
700
|
+
# app route would. A route reached through a mount carries its position
|
|
701
|
+
# as an Array, `[mount, route]` (`[3, 0, 5]` through an engine mounted in
|
|
702
|
+
# an engine), where an app route carries a bare Integer; so one engine
|
|
703
|
+
# mounted twice is two routes to its actions, as it is to a client. Only
|
|
704
|
+
# a Rails engine is followed: a Sidekiq::Web or a proc has no route set
|
|
705
|
+
# of ours to read and is skipped, as it always was. `engines: false`
|
|
706
|
+
# reads the one route set alone, for a caller that concatenates the lists
|
|
707
|
+
# of an app and its engines itself.
|
|
708
|
+
#
|
|
709
|
+
# Rails tries the routes in order with a mount standing where it is
|
|
710
|
+
# drawn, so the engine's routes are judged in that one order with the
|
|
711
|
+
# app's (see drop_shadowed): an app route ahead of the mount answers
|
|
712
|
+
# before the engine is asked, an engine route that matches answers and
|
|
713
|
+
# leaves an app route behind the mount unreached, and only a request the
|
|
714
|
+
# engine has no route for goes on (X-Cascade). A mount's `via:` limits
|
|
715
|
+
# the verbs it hands over: another verb is passed on, never answered by
|
|
716
|
+
# the engine.
|
|
717
|
+
#
|
|
718
|
+
# A parameter the mount and an engine route both name (`/{id}/.../{id}`)
|
|
719
|
+
# is not renamed: the template names it twice, the operation declares it
|
|
720
|
+
# once, and the README says so.
|
|
721
|
+
def rails_routes(app, engines: true)
|
|
722
|
+
drop_shadowed(collect_routes(app.routes, engines: engines, prefixes: [Prefix.new([], [])], parents: [],
|
|
723
|
+
visiting: [].freeze, reachable: true))
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# What a route set mounted at one variant of its mount's path is led by.
|
|
727
|
+
# `tokens` are the mount's path from the root (see join_paths).
|
|
728
|
+
# `shadowed_by` are the paths of the variants Rails tries first, which
|
|
729
|
+
# leave this one nothing that they accept: a mount at "/" under
|
|
730
|
+
# `scope "(:locale)"` is matched as `/{locale}` before it is matched as
|
|
731
|
+
# "/", so /posts is the locale "posts" asking for the engine's root, and
|
|
732
|
+
# the engine's own /posts is not reached through the second variant. The
|
|
733
|
+
# mount's path is matched as a whole, unanchored and with no way back
|
|
734
|
+
# into it once the engine has been asked, so a variant that fits the
|
|
735
|
+
# leading segments of a path takes it whether or not the engine has a
|
|
736
|
+
# route for the rest.
|
|
737
|
+
Prefix = Struct.new(:tokens, :shadowed_by)
|
|
738
|
+
|
|
739
|
+
# The descriptors of one route set (`set`, anything with `routes`), each
|
|
740
|
+
# path led by every one of `prefixes` (a Prefix for each variant of the
|
|
741
|
+
# mounts above it, one empty one at the top) and each `route:` led by
|
|
742
|
+
# `parents`. `reachable` is whether every mount above answers every
|
|
743
|
+
# request for its path (see unconditional?); a route behind one that
|
|
744
|
+
# does not may be passed over, so it hides nothing. `visiting` is the
|
|
745
|
+
# route sets above this one, so an engine that mounts itself, or an app
|
|
746
|
+
# and engine that mount each other, ends the walk instead of recursing
|
|
747
|
+
# for good. Rails would loop on such a request too; the audit must not
|
|
748
|
+
# hang on it.
|
|
749
|
+
#
|
|
750
|
+
# The descriptors still carry `unconditional:`; the caller runs
|
|
751
|
+
# drop_shadowed over the whole list, mounts expanded in place.
|
|
752
|
+
def collect_routes(set, engines:, prefixes:, parents:, visiting:, reachable:)
|
|
753
|
+
visiting += [set]
|
|
754
|
+
set.routes.each_with_index.flat_map do |route, index|
|
|
755
|
+
id = parents.empty? ? index : parents + [index]
|
|
621
756
|
requirements = route.requirements
|
|
757
|
+
if engines && (mounted = mounted_routes(route)) && visiting.none? { |above| above.equal?(mounted) }
|
|
758
|
+
found = collect_routes(mounted, engines: engines, prefixes: mount_prefixes(prefixes, route), parents: Array(id),
|
|
759
|
+
visiting: visiting, reachable: reachable && unconditional?(route))
|
|
760
|
+
verbs = route.verb.to_s.downcase.split("|")
|
|
761
|
+
next verbs.empty? ? found : found.select { |descriptor| verbs.include?(descriptor[:verb]) }
|
|
762
|
+
end
|
|
622
763
|
verb = route.verb.to_s
|
|
623
764
|
next [] if requirements[:controller].nil? || requirements[:action].nil?
|
|
624
765
|
|
|
@@ -627,9 +768,9 @@ module Permittable
|
|
|
627
768
|
# audit entirely. Expand it into the verbs it actually answers.
|
|
628
769
|
verb = ALL_VERBS if verb.empty?
|
|
629
770
|
|
|
630
|
-
variants = optional_variants(route.path.spec, requirements)
|
|
771
|
+
variants = optional_variants(route.path.spec, requirements, prefixes)
|
|
631
772
|
# Read once per route; drop_shadowed reads it, then strips it.
|
|
632
|
-
unconditional = unconditional?(route)
|
|
773
|
+
unconditional = reachable && unconditional?(route)
|
|
633
774
|
# One route can answer several verbs (`match via: [:patch, :put]`, and
|
|
634
775
|
# the PATCH|PUT pair resources generates); documenting only the first
|
|
635
776
|
# dropped the others from the export entirely. Verb by verb, then
|
|
@@ -638,12 +779,47 @@ module Permittable
|
|
|
638
779
|
verb.split("|").flat_map do |single|
|
|
639
780
|
variants.map do |path, shadows|
|
|
640
781
|
descriptor = { controller: requirements[:controller], action: requirements[:action],
|
|
641
|
-
verb: single.downcase, path: path, route:
|
|
782
|
+
verb: single.downcase, path: path, route: id, unconditional: unconditional }
|
|
642
783
|
shadows.empty? ? descriptor : descriptor.merge(shadows: shadows)
|
|
643
784
|
end
|
|
644
785
|
end
|
|
645
786
|
end
|
|
646
|
-
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# The Prefix of each variant of a mount's path under each of `prefixes`,
|
|
790
|
+
# outermost mount first, and within a mount present before absent, which
|
|
791
|
+
# is the order Rails' regexp tries them in. A variant is shadowed by every
|
|
792
|
+
# variant ahead of it: those of the same mount, and (through the
|
|
793
|
+
# `shadowed_by` it inherits) those of a mount above, judged where each
|
|
794
|
+
# was matched.
|
|
795
|
+
def mount_prefixes(prefixes, route)
|
|
796
|
+
own = variant_tokens(route.path.spec, route.requirements)
|
|
797
|
+
prefixes.flat_map do |above|
|
|
798
|
+
own.each_with_index.map do |tokens, index|
|
|
799
|
+
Prefix.new(join_paths(above.tokens, tokens),
|
|
800
|
+
above.shadowed_by + own.take(index).map { |earlier| join_paths(above.tokens, earlier) })
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# The route set a mount route hands requests to, or nil for any other
|
|
806
|
+
# route. Rails itself asks `route.app.engine?` (RoutesInspector does)
|
|
807
|
+
# and reads the set off `rack_app`. A route that cannot answer, such as a
|
|
808
|
+
# hand-built duck, is not a mount.
|
|
809
|
+
def mounted_routes(route)
|
|
810
|
+
return unless route.respond_to?(:app) && route.app.respond_to?(:engine?) && route.app.engine?
|
|
811
|
+
|
|
812
|
+
engine = route.app.rack_app
|
|
813
|
+
engine.routes if engine.respond_to?(:routes)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# `outer` then `inner`, as one path: the slash the mount ends in and the
|
|
817
|
+
# one the inner path starts with are one slash, and a route to the
|
|
818
|
+
# engine's root ("/") adds nothing to the mount's own path.
|
|
819
|
+
def join_paths(outer, inner)
|
|
820
|
+
inner = inner.drop(1) if outer.last == "/" && inner.first == "/"
|
|
821
|
+
inner = [] if inner == ["/"] && !outer.empty?
|
|
822
|
+
join_literals(outer + inner)
|
|
647
823
|
end
|
|
648
824
|
|
|
649
825
|
# A route parameter as optional_variants reads it: a `:name` segment or a
|
|
@@ -704,13 +880,23 @@ module Permittable
|
|
|
704
880
|
# string: `to_s` prints an escaped parenthesis in a literal as a bare
|
|
705
881
|
# one, which a string scan then read as a group. A Journey-shaped stand-in
|
|
706
882
|
# may carry a String spec instead (string_variants).
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
883
|
+
#
|
|
884
|
+
# `prefixes` are the Prefix of each variant of the mounts the route sits
|
|
885
|
+
# under (one empty one, none, by default). Each is put in front of every
|
|
886
|
+
# variant, the mount's variants outermost, which is the order Rails'
|
|
887
|
+
# regexp tries them in; a path a mount variant ahead of its own takes
|
|
888
|
+
# first is left out (see Prefix), and the shadowing above then judges the
|
|
889
|
+
# rest.
|
|
890
|
+
def optional_variants(spec, requirements = {}, prefixes = [Prefix.new([], [])])
|
|
891
|
+
# The route's own variants are judged before a mount's path is put in
|
|
892
|
+
# front of them: an empty one (`(:locale(/:region))` with nothing
|
|
893
|
+
# present) is no request path, but joined onto a mount it would read as
|
|
894
|
+
# the mount's own path, which Rails does not hand to that route.
|
|
895
|
+
own = variant_tokens(spec, requirements).select { |tokens| request_path?(tokens) }
|
|
896
|
+
variants = prefixes.flat_map do |prefix|
|
|
897
|
+
own.map { |tokens| join_paths(prefix.tokens, tokens) }
|
|
898
|
+
.reject { |joined| prefix.shadowed_by.any? { |earlier| shadowed?(earlier, joined.first(earlier.length)) } }
|
|
899
|
+
end
|
|
714
900
|
# No group, no choice: nothing for one variant to shadow.
|
|
715
901
|
return { render(variants.first) => [] } if variants.one?
|
|
716
902
|
|
|
@@ -732,6 +918,17 @@ module Permittable
|
|
|
732
918
|
kept.to_a.reverse.to_h
|
|
733
919
|
end
|
|
734
920
|
|
|
921
|
+
# The variants of a path spec, each a list of tokens with adjacent text
|
|
922
|
+
# joined (see join_literals), before any is dropped as shadowed.
|
|
923
|
+
def variant_tokens(spec, requirements)
|
|
924
|
+
variants = if spec.respond_to?(:type)
|
|
925
|
+
tree_variants(spec, requirements)
|
|
926
|
+
else
|
|
927
|
+
string_variants(spec.to_s.sub(FORMAT_GROUP, ""), requirements).first
|
|
928
|
+
end
|
|
929
|
+
variants.map { |tokens| join_literals(tokens) }
|
|
930
|
+
end
|
|
931
|
+
|
|
735
932
|
# A Journey tree's variants, each a list of tokens: literal text and
|
|
736
933
|
# Param. A group yields its contents' variants, then the empty one —
|
|
737
934
|
# present before absent, as Rails' regexp tries them — except the
|