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.
@@ -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
- max = spec.end && spec.exclude_end? ? spec.end - 1 : spec.end
399
- [spec.begin, max]
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.
@@ -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 and the exporter does not guess: a path segment arrives as
492
- # a string, so that is what it is documented as.
493
- def with_path_parameters(operation, path)
494
- variables = path.scan(/\{(\w+)\}/).flatten
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
- { "name" => name, "in" => "path", "required" => true, "schema" => { "type" => "string" } }
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
- out["parameters"] = parameters if key == "requestBody"
504
- out[key] = value
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 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.
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
- def rails_routes(app)
620
- descriptors = app.routes.routes.each_with_index.flat_map do |route, index|
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: index, unconditional: unconditional }
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
- drop_shadowed(descriptors)
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
- 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) }
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