permittable 0.11.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.
@@ -16,22 +16,31 @@ module Permittable
16
16
  # the same as `{}`. JSON Schema cannot say that — `type: integer` reads
17
17
  # null as a present value of the wrong type — so the schema rejects a
18
18
  # null the server would accept and ignore.
19
- # * `validate:`/`transform:`/`finalize` are opaque callables — they never
20
- # change what a client may SEND, so fields carrying them are flagged
21
- # with `x-permittable-*` extensions rather than mistranslated.
19
+ # * `validate:`/`transform:`/`finalize` are opaque callables the schema
20
+ # cannot run, so the field carrying one (or, for `finalize`, the rule's
21
+ # own object) is flagged with an `x-permittable-*` extension rather than
22
+ # mistranslated. `transform:` only reshapes a value already accepted, so
23
+ # it changes no verdict; `validate:` and `finalize` can refuse a value.
22
24
  #
23
- # All three leave the schema STRICTER than the server, never looser: a
25
+ # The first two leave the schema STRICTER than the server, never looser: a
24
26
  # client that validates against the published document is conservative,
25
- # never surprised by a 422. spec/schema_conformance_spec.rb holds that line,
27
+ # never surprised by a 422. The third does not: `validate:` and `finalize`
28
+ # leave it LOOSER (below). spec/schema_conformance_spec.rb holds the line,
26
29
  # asserting the direction of every divergence it permits.
27
30
  #
28
31
  # Where a rule has no JSON Schema keyword, the schema is LOOSER instead,
29
32
  # and a client following it can still earn a 422. Each such rule stays
30
- # visible on its own field, and the conformance spec labels each one
31
- # rather than letting it pass as agreement:
33
+ # visible in the schema (on its own field, or for `finalize` on the rule's
34
+ # object), and the conformance spec labels each one rather than letting it
35
+ # pass as agreement:
32
36
  # * a `:json` field's `max_depth:` — `x-permittable-max-depth`;
33
37
  # * a Range of non-numbers (`in: "a".."m"`) — `x-permittable-range`;
34
38
  # * a `validate:` proc — `x-permittable-custom-validation`;
39
+ # * a `finalize` block, whose `violate!` cross-field checks no field's
40
+ # schema can carry — `x-permittable-finalize` on the rule's object;
41
+ # * a `sensitive:` field's `in:` and `format:`, withheld because they
42
+ # would publish the secret itself — the same marker (see
43
+ # withholds_constraints?);
35
44
  # * a `normalize:` step, which the server runs BEFORE it checks, so
36
45
  # " " passes a minLength of 3 and is then absent —
37
46
  # `x-permittable-normalize`;
@@ -66,8 +75,16 @@ module Permittable
66
75
  # `root:` envelope when the rule declares one. The wrapper itself stays
67
76
  # permissive even under `unknown: :error` — the runtime never inspects
68
77
  # sibling keys outside the root.
78
+ #
79
+ # `finalize` is the one opaque callable that belongs to the rule, not to
80
+ # a field: it runs once every field has passed and can still refuse the
81
+ # payload (`ends_on` before `starts_on`), which no per-field keyword can
82
+ # say. The flag therefore rides on the rule's own object, inside the
83
+ # `root:` wrapper where the fields are, so a client or generator that
84
+ # reads the document knows the schema alone is not the whole verdict.
69
85
  def rule(permit_rule)
70
86
  schema = object(permit_rule[:fields], unknown: permit_rule[:unknown])
87
+ schema["x-permittable-finalize"] = true if permit_rule[:finalize]
71
88
  return schema unless permit_rule[:root]
72
89
 
73
90
  root = permit_rule[:root].to_s
@@ -125,12 +142,31 @@ module Permittable
125
142
  def scalar_schema(field)
126
143
  schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup
127
144
  apply_format_name!(schema, field)
128
- apply_in!(schema, field)
145
+ withheld = withholds_constraints?(field)
146
+ apply_in!(schema, field) unless withheld
129
147
  apply_string_bounds!(schema, field)
130
- apply_pattern!(schema, field[:format])
148
+ apply_pattern!(schema, field[:format]) unless withheld
149
+ schema["x-permittable-custom-validation"] = true if withheld
131
150
  schema
132
151
  end
133
152
 
153
+ # A `sensitive:` field's `in:` list/Range and hand-written `format:` ARE
154
+ # the secret: an enum of the only accepted PINs is an allowlist, and
155
+ # `pattern: "^FMT_SECRET$"` or a 4000..4999 range names or narrows the
156
+ # value the same way, so publishing them would defeat the redaction as
157
+ # surely as publishing a `default:` (see annotate). They are left out,
158
+ # with `x-permittable-custom-validation` as the only signal, which makes
159
+ # the exported schema LOOSER than the server for that field. A
160
+ # `format:` PRESET is the exception: it names a public rule, not a
161
+ # secret, so it keeps its `format` and `pattern`. `field[:sensitive]` is
162
+ # already resolved onto a cascaded child, so a child counts. Only a field
163
+ # that actually declares one of the two is flagged.
164
+ def withholds_constraints?(field)
165
+ return false unless field[:sensitive]
166
+
167
+ !field[:in].nil? || (!field[:format].nil? && field[:format_name].nil?)
168
+ end
169
+
134
170
  # A `:json` field's shape is deliberately undeclared, so the schema says
135
171
  # "an object" and carries only the bounds the field does declare. JSON
136
172
  # Schema has no nesting-depth keyword, so `max_depth:` stays visible as an
@@ -293,17 +329,39 @@ module Permittable
293
329
  !allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
294
330
  end
295
331
 
332
+ # Every field whose schema admits a string gets its bounds here: :string,
333
+ # and the string encodings of :decimal, :date and :datetime. minLength
334
+ # and maxLength only ever constrain the string branch, so the number
335
+ # branch of a :decimal is untouched. `length:` itself is :string-only.
296
336
  def apply_string_bounds!(schema, field)
297
- return unless field[:type] == :string
337
+ return unless Array(schema["type"]).include?("string")
298
338
 
299
339
  min, max = length_bounds(field[:length])
300
- # "" is ABSENT and an absent required field violates, so a required
301
- # string can never validly be empty — the schema says so.
302
- min = 1 if field[:required] && min.to_i < 1
340
+ min = 1 if blank_floor?(field) && min.to_i < 1
303
341
  schema["minLength"] = min if min
304
342
  schema["maxLength"] = max if max
305
343
  end
306
344
 
345
+ # "" is ABSENT whatever the type, and an absent required field violates,
346
+ # so a required field can never validly be "" — the schema says so. For
347
+ # :date, :datetime and :decimal that is the only thing the schema can say
348
+ # about a blank: `format` is an annotation, and "decimal" is no format a
349
+ # validator knows. (A whitespace-only string needs no floor there: those
350
+ # types refuse it as invalid_type, and the schema never claims to catch a
351
+ # cast failure. For :date and :datetime `format` at least names the
352
+ # failure to a validator that asserts it; for :decimal nothing does, so
353
+ # " " passes the schema and earns a 422. That is a documented looser
354
+ # case, the same one as "abc" for a :decimal, and deliberately has no
355
+ # `pattern` to close it.)
356
+ #
357
+ # A nullable field is the exception: it reads "" as an explicit null, a
358
+ # value rather than an absence, so a floor would reject what the server
359
+ # takes. A nullable :string keeps the floor it has always had (stricter
360
+ # than the server, the safe direction); the other types start without.
361
+ def blank_floor?(field)
362
+ field[:required] && (field[:type] == :string || !field[:nullable])
363
+ end
364
+
307
365
  def apply_pattern!(schema, regexp)
308
366
  return unless regexp
309
367
 
@@ -365,13 +423,18 @@ module Permittable
365
423
  # untranslatable or opaque fact in this file is handled: left out, with
366
424
  # the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
367
425
  # sensitive`) as the only signal that something is missing.
426
+ #
427
+ # The same goes for an array whose elements hold a sensitive field at
428
+ # any depth (see holds_sensitive?): its `default:`/`example:` carries
429
+ # that field's value, so it is omitted too.
368
430
  def annotate(schema, field)
369
431
  decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
370
- unless field[:sensitive]
432
+ unless holds_sensitive?(field)
371
433
  schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
372
434
  schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
373
435
  end
374
- schema["description"] = field[:desc] if field[:desc]
436
+ # dup: the stored desc is frozen; the export is editable and detached.
437
+ schema["description"] = field[:desc].dup if field[:desc]
375
438
  if field[:sensitive]
376
439
  schema["writeOnly"] = true
377
440
  schema["x-permittable-sensitive"] = true
@@ -382,6 +445,30 @@ module Permittable
382
445
  schema
383
446
  end
384
447
 
448
+ # Is this field sensitive, or does it contain a sensitive field at any
449
+ # depth? A container's `default:`/`example:` is a whole value, so a
450
+ # sensitive sub-field's value is inside it. `array :cards, default:
451
+ # [{}]` with `optional :pan, sensitive: true, default: "4111..."` even
452
+ # stores `[{"pan" => "4111..."}]`, because the walker fills in each
453
+ # element's sub-field defaults (see AuthoredValues). Omitting pan's own
454
+ # default protected nothing while the array still published the value.
455
+ #
456
+ # The whole container value is omitted. Redacting only the sensitive
457
+ # keys inside it was the alternative, and it would mislead. Without the
458
+ # key, `[{}]` documents a default the server never hands out, since it
459
+ # fills pan in. An example without its required secret fails the very
460
+ # items schema it is attached to. A placeholder can fail the
461
+ # sub-field's own format or in:. The field's own default is omitted
462
+ # rather than replaced for the same reasons.
463
+ #
464
+ # The answer is read off the declaration, not the value, so even
465
+ # `default: []` is withheld. A value stored as authored (beside a
466
+ # `transform:`) can key its hashes by String or Symbol, and a check
467
+ # that has to find the sensitive key there is one more way to miss it.
468
+ def holds_sensitive?(field)
469
+ field[:sensitive] || Array(field[:fields]).any? { |child| holds_sensitive?(child) }
470
+ end
471
+
385
472
  # The server checks the NORMALIZED string, so minLength/maxLength/pattern
386
473
  # describe a value the client never sends: under `normalize: :squish`,
387
474
  # " " satisfies a minLength of 3 and then squishes to "" (absent), and
@@ -188,7 +188,9 @@ module Permittable
188
188
  operation = {}
189
189
  key = controller_key(controller)
190
190
  operation["operationId"] = "#{key.tr('/', '_')}_#{action}" if key && action != "*"
191
- operation["description"] = rule[:desc] if rule[:desc]
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]
192
194
  operation["requestBody"] = rule_request_body(rule)
193
195
  operation["responses"] = responses_for(rule)
194
196
  # The docs must not promise a 422 the server doesn't yet send. Only
@@ -196,6 +198,12 @@ module Permittable
196
198
  # Permittable.mode is runtime configuration the export can't see.
197
199
  operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
198
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]
199
207
  operation
200
208
  end
201
209
 
@@ -249,15 +257,26 @@ module Permittable
249
257
  # once — not counted as an operation colliding with itself. `template`
250
258
  # is the route's own spelling when collapse_shapes placed it under
251
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.
252
267
  def route_targets(routes)
253
268
  return {} if routes.nil?
254
269
 
255
- routes.group_by { |route| [route[:controller].to_s, route[:action].to_s] }
270
+ routes.select { |route| OPENAPI_VERBS.include?(verb_of(route)) }
271
+ .group_by { |route| [route[:controller].to_s, route[:action].to_s] }
256
272
  .transform_values do |matching|
257
273
  matching.map { |route| [route[:path], verb_of(route), route[:template]] }.uniq
258
274
  end
259
275
  end
260
276
 
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
+
261
280
  # OpenAPI forbids two path templates that differ only in their variable
262
281
  # names: `/{locale}` and `/{slug}` are one path to it. rails_routes keeps
263
282
  # one route's own variants apart, but two routes can still spell one
@@ -665,6 +684,14 @@ module Permittable
665
684
  # named in its shadows rather than emitted. Distinct shapes are both
666
685
  # kept; OpenAPI allows `/x/new` beside `/x/{a}`.
667
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
+ #
668
695
  # The list is then reversed, which puts every group's ABSENT variant
669
696
  # first at every nesting level, the order the document lists them in.
670
697
  # The variants of one route share an operation, and it is the operationId
@@ -689,6 +716,8 @@ module Permittable
689
716
 
690
717
  kept = {}
691
718
  first_of_shape = {}
719
+ # Such a variant matches no request, so it shadows nothing either.
720
+ variants = variants.select { |tokens| request_path?(tokens) }
692
721
  variants.each_with_index do |tokens, index|
693
722
  next if variants.take(index).any? { |earlier| shadowed?(earlier, tokens) }
694
723
 
@@ -784,6 +813,16 @@ module Permittable
784
813
  path.empty? ? "/" : path
785
814
  end
786
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
+
787
826
  # Whether Rails, trying `earlier` first, leaves `later` no URL: wherever
788
827
  # `later` has a token, `earlier` accepts everything it does. Text must be
789
828
  # equal; a parameter accepts text its pattern matches whole, and another
@@ -23,12 +23,99 @@ module Permittable
23
23
  # this array in place and ActionDispatch reads the same object per
24
24
  # request, so a name registered when a controller loads is seen by the
25
25
  # next request. It is the array, not a snapshot, that has to be fed.
26
+ #
27
+ # ActiveRecord is a second sink: see FilterAttributesFeed, which also
28
+ # explains why it is fed here and installed by the initializer below.
29
+ feed = filter_attributes_feed
26
30
  ::Permittable.on_sensitive_parameter do |name|
27
31
  filters = app.config.filter_parameters
28
32
  filters << name unless filters.include?(name)
33
+ feed.add(name)
29
34
  end
30
35
  end
31
36
 
37
+ # AFTER ActiveRecord's own hook, not before: both run when ActiveRecord::Base
38
+ # loads, in the order they were registered, and that one does
39
+ # `filter_attributes += config.filter_parameters`. Running first, the feed
40
+ # would add each name and that copy would add it again as a String (it holds
41
+ # Strings whenever precompile_filter_parameters is off, as on Rails 6.1).
42
+ initializer "permittable.filter_attributes",
43
+ after: "active_record.set_filter_attributes" do
44
+ feed = filter_attributes_feed
45
+ ::ActiveSupport.on_load(:active_record) { feed.attach(self) }
46
+ end
47
+
48
+ # The second sink for sensitive names: ActiveRecord::Base.filter_attributes.
49
+ #
50
+ # It is a snapshot that never sees a name registered after it loaded. Its
51
+ # `filter_attributes` is copied from config.filter_parameters exactly once,
52
+ # when ActiveRecord::Base loads, and in a real app that is BEFORE any
53
+ # controller registers anything: the first controller's own `model: Person`
54
+ # loads it. So the copy holds only the proc the initializer appended, which
55
+ # cannot redact an Integer in the SQL bind log (`[["pin_code", 4321]]`, where
56
+ # #inspect, handed an already-inspected String, was fine). Each name is
57
+ # therefore also fed to the model base class directly. The setter drops the
58
+ # memoized inspection filter, and only names that are missing are added, so
59
+ # the list holds one entry per name however often a reloaded controller
60
+ # registers it.
61
+ #
62
+ # An app that ASSIGNS `filter_attributes = [...]` (an initializer, or a
63
+ # to_prepare block, which runs on every reload) rather than appending to it
64
+ # discards everything fed in so far, and a name that was registered only once
65
+ # would never come back. So every registration restores EVERY name registered
66
+ # so far that is missing, not just its own: the next contract to load, or the
67
+ # next reload re-declaring one, heals the list. (A to_prepare of the gem's own
68
+ # would not help: railtie blocks run before the app's, so the app's
69
+ # assignment would still come last.)
70
+ #
71
+ # The read-modify-write is done under a lock, as two threads loading
72
+ # contracts at once would otherwise each write back a list that lacks the
73
+ # other's name. Nothing inside the lock calls back into the sink or loads
74
+ # ActiveRecord, so it cannot deadlock.
75
+ #
76
+ # ActiveRecord is hooked ONCE for the process, in #attach, rather than once
77
+ # per name: on_load runs the block at once when ActiveRecord is already
78
+ # loaded, waits for it otherwise, and never fires in an app without it, where
79
+ # a hook per registration would queue forever across every code reload.
80
+ class FilterAttributesFeed
81
+ def initialize
82
+ @names = {}
83
+ @base = nil
84
+ @lock = Mutex.new
85
+ end
86
+
87
+ # Called with ActiveRecord::Base once it has loaded.
88
+ def attach(base)
89
+ @lock.synchronize do
90
+ @base = base
91
+ restore
92
+ end
93
+ end
94
+
95
+ def add(name)
96
+ @lock.synchronize do
97
+ @names[name] = true
98
+ restore if @base
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ # The caller holds the lock.
105
+ def restore
106
+ missing = @names.keys - @base.filter_attributes
107
+ @base.filter_attributes += missing unless missing.empty?
108
+ end
109
+ end
110
+
111
+ # Shared by the two initializers, which Rails runs as instance_exec on this
112
+ # one object. Memoized rather than built in the first, so the second does not
113
+ # depend on the order Rails settles on for initializers that name no target.
114
+ def filter_attributes_feed
115
+ @filter_attributes_feed ||= FilterAttributesFeed.new
116
+ end
117
+ private :filter_attributes_feed
118
+
32
119
  rake_tasks do
33
120
  load File.expand_path("tasks/openapi.rake", __dir__)
34
121
  load File.expand_path("tasks/generate.rake", __dir__)
@@ -96,7 +96,10 @@ module Permittable
96
96
  end
97
97
 
98
98
  def matching(regexp)
99
- @expected[:format] = regexp
99
+ # `format: "email"` names a preset exactly as `format: :email` does
100
+ # (see resolve_format!), so the matcher reads a String the same way;
101
+ # as a Symbol it also reports the preset the same way in a failure.
102
+ @expected[:format] = regexp.is_a?(String) ? regexp.to_sym : regexp
100
103
  self
101
104
  end
102
105
 
@@ -364,8 +367,9 @@ module Permittable
364
367
  "expected the field to be #{expected}, but it is #{actual}" unless actual == expected
365
368
  end
366
369
 
367
- # `matching(:email)` asserts the preset by name, `matching(/re/)` the
368
- # Regexp itself.
370
+ # `matching(:email)` (or `matching("email")`, normalized to a Symbol by
371
+ # `matching`) asserts the preset by name, `matching(/re/)` the Regexp
372
+ # itself.
369
373
  def format_mismatch(field, expected)
370
374
  return option_mismatch(field, :format, expected) unless expected.is_a?(Symbol)
371
375
  return if field[:format_name] == expected
@@ -374,7 +378,21 @@ module Permittable
374
378
  end
375
379
 
376
380
  def default_mismatch(field, expected)
377
- option_mismatch(field, :default, expected) unless field.key?(:default) && field[:default] == cast_default(field, expected)
381
+ option_mismatch(field, :default, expected) unless field.key?(:default) && same_default?(field, expected)
382
+ end
383
+
384
+ # A :json default is stored as authored, but the action reads it as
385
+ # a HashWithIndifferentAccess, String-keyed all the way down. The
386
+ # declaration's spelling, `with_default(theme: "dark")`, and the
387
+ # action's, `with_default("theme" => "dark")`, name the same default,
388
+ # so both sides are read that way before comparing.
389
+ def same_default?(field, expected)
390
+ declared = field[:default]
391
+ if field[:kind] == :json && declared.is_a?(Hash) && expected.is_a?(Hash)
392
+ ActiveSupport::HashWithIndifferentAccess.new(declared) == ActiveSupport::HashWithIndifferentAccess.new(expected)
393
+ else
394
+ declared == cast_default(field, expected)
395
+ end
378
396
  end
379
397
 
380
398
  # A contract stores its `default:` as the field reads it — cast,
@@ -483,8 +501,11 @@ module Permittable
483
501
  end
484
502
  end
485
503
 
504
+ # An anonymous controller class has no name, and a Contract instance
505
+ # never does, so the fallback names the kind of subject it is.
486
506
  def subject_name
487
- (@subject.respond_to?(:name) && @subject.name) || "the controller"
507
+ (@subject.respond_to?(:name) && @subject.name) ||
508
+ (@subject.is_a?(Permittable::Contract) ? "the contract" : "the controller")
488
509
  end
489
510
 
490
511
  def path_label
@@ -616,15 +637,33 @@ module Permittable
616
637
  attr_accessor :params
617
638
  end
618
639
  mirror_contract_unknown_check(host) if @subject.is_a?(Permittable::Contract)
640
+ action = @action || rule[:actions].first || "call"
641
+ label_instrumentation(host, action)
619
642
  host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
620
643
  instance = host.new
621
644
  instance.params = @params
622
- action = @action || rule[:actions].first || "call"
623
645
  violations = instance.permittable_violations(action)
624
646
  result = violations.empty? ? instance.permitted_params(action) : nil
625
647
  [violations, result]
626
648
  end
627
649
 
650
+ # A rejection instruments invalid_parameters.permittable exactly as a
651
+ # request would. The host is anonymous, so without this the payload
652
+ # carried controller: nil, action: nil — a subscriber following the
653
+ # README's shape (`payload[:controller].tr("/", ".")`) raised out of
654
+ # the matcher. Label it the way the subject itself would: a
655
+ # controller by its path (or name, or "the contract" when anonymous —
656
+ # an anonymous controller's controller_path is nil), a Contract as
657
+ # Contract#call does.
658
+ def label_instrumentation(host, action)
659
+ label = if @subject.is_a?(Permittable::Contract) then "Permittable::Contract"
660
+ elsif @subject.respond_to?(:controller_path) then @subject.controller_path || subject_name
661
+ else subject_name
662
+ end
663
+ host.define_method(:permittable_controller_name) { label }
664
+ host.define_method(:permittable_action_name) { action }
665
+ end
666
+
628
667
  # Mirrors Permittable::Contract's own host override verbatim (see
629
668
  # lib/permittable/contract.rb): standalone input has no router and no
630
669
  # request, so nothing is exempt from unknown: checking, top level or
@@ -666,8 +705,11 @@ module Permittable
666
705
  violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
667
706
  end
668
707
 
708
+ # An anonymous controller class has no name, and a Contract instance
709
+ # never does, so the fallback names the kind of subject it is.
669
710
  def subject_name
670
- (@subject.respond_to?(:name) && @subject.name) || "the contract"
711
+ (@subject.respond_to?(:name) && @subject.name) ||
712
+ (@subject.is_a?(Permittable::Contract) ? "the contract" : "the controller")
671
713
  end
672
714
 
673
715
  def action_label
@@ -4,7 +4,8 @@
4
4
  # summary goes to stderr.
5
5
  #
6
6
  # bin/rails permittable:generate # every uncovered controller not
7
- # # left out by Permittable.audit_ignore
7
+ # # left out by Permittable.audit_ignore,
8
+ # # for the actions it defines
8
9
  # bin/rails "permittable:generate[UsersController]" # one controller, even if covered
9
10
  namespace :permittable do
10
11
  desc "Draft Permittable contracts from models and existing permit calls"
@@ -17,12 +18,13 @@ namespace :permittable do
17
18
  controllers = bases.flat_map(&:descendants).uniq.select(&:name)
18
19
 
19
20
  # { controller => actions to leave out of its draft }. A named controller
20
- # is drafted whole, even if covered or ignored; the sweep skips both, and
21
- # leaves out what Permittable.audit_ignore names (see Generator.targets).
21
+ # is drafted even if covered or ignored; the sweep skips both, and leaves
22
+ # out what Permittable.audit_ignore names. Either leaves out an action the
23
+ # controller does not define (see Generator.targets and named_target).
22
24
  if task_args[:controller]
23
25
  controllers = controllers.select { |c| c.name == task_args[:controller] }
24
26
  abort "Permittable: no controller named #{task_args[:controller]} was found" if controllers.empty?
25
- targets = controllers.to_h { |c| [c, []] }
27
+ targets = controllers.to_h { |c| [c, Permittable::Generator.named_target(c)] }
26
28
  else
27
29
  targets = Permittable::Generator.targets(controllers)
28
30
  end
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.11.0".freeze
2
+ VERSION = "0.11.1".freeze
3
3
  end