permittable 0.11.0 → 0.12.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 +82 -0
- data/README.md +394 -36
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +87 -13
- data/lib/permittable/column_guard.rb +60 -11
- data/lib/permittable/contract.rb +54 -2
- data/lib/permittable/error_envelope.rb +97 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +333 -61
- data/lib/permittable/json_schema.rb +116 -17
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +201 -36
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +597 -97
- metadata +2 -2
data/lib/permittable/rspec.rb
CHANGED
|
@@ -96,7 +96,10 @@ module Permittable
|
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def matching(regexp)
|
|
99
|
-
|
|
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
|
|
|
@@ -148,7 +151,7 @@ module Permittable
|
|
|
148
151
|
@subject = resolve_subject(subject)
|
|
149
152
|
raise ArgumentError, negated_qualifier_message unless @expected.empty?
|
|
150
153
|
|
|
151
|
-
%i[undeclared too_deep].include?(locate)
|
|
154
|
+
%i[undeclared too_deep misindexed].include?(locate)
|
|
152
155
|
end
|
|
153
156
|
|
|
154
157
|
def failure_message
|
|
@@ -156,6 +159,7 @@ module Permittable
|
|
|
156
159
|
case @status
|
|
157
160
|
when :no_rule then "#{subject} it #{@problem}"
|
|
158
161
|
when :root_prefixed then "#{subject} #{root_prefix_hint}"
|
|
162
|
+
when :misindexed then "#{subject} #{misindexed_reason}"
|
|
159
163
|
when :too_deep
|
|
160
164
|
"#{subject} it is deeper than the opaque :json field #{label_for(@opaque[:path])} allows " \
|
|
161
165
|
"(max_depth: #{@opaque[:field][:max_depth]})"
|
|
@@ -202,18 +206,21 @@ module Permittable
|
|
|
202
206
|
# The one lookup both directions share. Sets @status to :no_rule,
|
|
203
207
|
# :declared, :opaque (the path runs into a :json field, which accepts
|
|
204
208
|
# any nested key without declaring it), :too_deep (it runs into one
|
|
205
|
-
# deeper than its max_depth: allows), :
|
|
206
|
-
#
|
|
209
|
+
# deeper than its max_depth: allows), :misindexed (it puts an [n] on
|
|
210
|
+
# a field the runtime refuses an Array for, or a second one on an
|
|
211
|
+
# array), :root_prefixed (the path starts with the rule's root: and
|
|
212
|
+
# resolves without it), or :undeclared.
|
|
207
213
|
# Every per-run ivar is reset first: a matcher object can be reused
|
|
208
214
|
# on another subject, and must not answer from the previous run.
|
|
209
215
|
def locate
|
|
210
|
-
@rule = @field = @opaque = @problem = @missing_among = nil
|
|
216
|
+
@rule = @field = @opaque = @misindexed = @problem = @missing_among = nil
|
|
211
217
|
@mismatches = []
|
|
212
218
|
@rule = resolve_rule(@subject)
|
|
213
219
|
return @status = :no_rule unless @rule
|
|
214
220
|
|
|
215
221
|
@field = resolve_field(@rule[:fields], @segments)
|
|
216
222
|
@status = if @field then :declared
|
|
223
|
+
elsif @misindexed then :misindexed
|
|
217
224
|
elsif @opaque then opaque_within_depth? ? :opaque : :too_deep
|
|
218
225
|
elsif root_prefixed? then :root_prefixed
|
|
219
226
|
else :undeclared
|
|
@@ -247,6 +254,7 @@ module Permittable
|
|
|
247
254
|
# field is opaque: it has no :fields, yet lets any nested key through,
|
|
248
255
|
# so a path running past one is recorded rather than called missing,
|
|
249
256
|
# along with how many container levels the rest of the path needs.
|
|
257
|
+
# An [n] on a declared field is held to the field's shape on the way.
|
|
250
258
|
def resolve_field(fields, segments, depth = 0)
|
|
251
259
|
name = segments[depth][:name].to_sym
|
|
252
260
|
field = fields.find { |f| f[:name] == name }
|
|
@@ -254,17 +262,44 @@ module Permittable
|
|
|
254
262
|
@missing_among = fields
|
|
255
263
|
return nil
|
|
256
264
|
end
|
|
265
|
+
if segments[depth][:indexes] > index_levels(field)
|
|
266
|
+
@misindexed = { path: field_path(segments, depth), field: field }
|
|
267
|
+
return nil
|
|
268
|
+
end
|
|
257
269
|
return field if depth == segments.length - 1
|
|
258
270
|
|
|
259
271
|
if field[:kind] == :json
|
|
260
|
-
@opaque = { path: segments
|
|
261
|
-
steps: steps_below(segments, depth) }
|
|
272
|
+
@opaque = { path: field_path(segments, depth), field: field, steps: steps_below(segments, depth) }
|
|
262
273
|
return nil
|
|
263
274
|
end
|
|
264
275
|
|
|
265
276
|
resolve_field(field[:fields] || [], segments, depth + 1)
|
|
266
277
|
end
|
|
267
278
|
|
|
279
|
+
def field_path(segments, depth)
|
|
280
|
+
segments.take(depth + 1).map { |seg| seg[:name] }.join(".")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# How many [n] the runtime takes on a field's own segment. An array
|
|
284
|
+
# is one level deep (`of:` a scalar type, or with a block of hashes;
|
|
285
|
+
# the DSL has no array of arrays), and every other field refuses an
|
|
286
|
+
# Array as invalid_type, a :json field included: it takes a Hash.
|
|
287
|
+
# Below a :json field the path is opaque, [n] and all.
|
|
288
|
+
def index_levels(field)
|
|
289
|
+
field[:kind] == :array ? 1 : 0
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def misindexed_reason
|
|
293
|
+
field = @misindexed[:field]
|
|
294
|
+
label = label_for(@misindexed[:path])
|
|
295
|
+
case field[:kind]
|
|
296
|
+
when :array then "#{label} is an array of #{field[:fields] ? 'hashes' : ":#{field[:of]}"}, not an array of arrays"
|
|
297
|
+
when :nested then "#{label} is a nested hash, not an array"
|
|
298
|
+
when :json then "#{label} is an opaque :json field, which takes a Hash, not an array"
|
|
299
|
+
else "#{label} is a :#{field[:type]} field, not an array"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
268
303
|
# Each key or [n] step past the :json field descends into one more
|
|
269
304
|
# container — the same count the runtime's max_depth: check makes,
|
|
270
305
|
# where the field's own Hash is the first level and arrays count too.
|
|
@@ -289,7 +324,7 @@ module Permittable
|
|
|
289
324
|
missing_among = @missing_among
|
|
290
325
|
found = resolve_field(@rule[:fields], @segments.drop(1)) || (@opaque && opaque_within_depth?)
|
|
291
326
|
@missing_among = missing_among
|
|
292
|
-
@opaque = nil
|
|
327
|
+
@opaque = @misindexed = nil
|
|
293
328
|
found ? true : false
|
|
294
329
|
end
|
|
295
330
|
|
|
@@ -304,8 +339,10 @@ module Permittable
|
|
|
304
339
|
end
|
|
305
340
|
|
|
306
341
|
# Accepts the runtime's own path form too — violation details say
|
|
307
|
-
# "line_items[0].sku" — so a path copied from one resolves
|
|
308
|
-
# indexes are
|
|
342
|
+
# "line_items[0].sku" — so a path copied from one resolves. The [n]
|
|
343
|
+
# indexes are not walked: each must land on an array field (see
|
|
344
|
+
# index_levels), except below a :json field, where they count only
|
|
345
|
+
# as depth.
|
|
309
346
|
def path_segments!(path)
|
|
310
347
|
raws = path.split(".", -1)
|
|
311
348
|
matches = raws.map { |raw| SEGMENT.match(raw) }
|
|
@@ -364,8 +401,9 @@ module Permittable
|
|
|
364
401
|
"expected the field to be #{expected}, but it is #{actual}" unless actual == expected
|
|
365
402
|
end
|
|
366
403
|
|
|
367
|
-
# `matching(:email)`
|
|
368
|
-
# Regexp
|
|
404
|
+
# `matching(:email)` (or `matching("email")`, normalized to a Symbol by
|
|
405
|
+
# `matching`) asserts the preset by name, `matching(/re/)` the Regexp
|
|
406
|
+
# itself.
|
|
369
407
|
def format_mismatch(field, expected)
|
|
370
408
|
return option_mismatch(field, :format, expected) unless expected.is_a?(Symbol)
|
|
371
409
|
return if field[:format_name] == expected
|
|
@@ -374,7 +412,21 @@ module Permittable
|
|
|
374
412
|
end
|
|
375
413
|
|
|
376
414
|
def default_mismatch(field, expected)
|
|
377
|
-
option_mismatch(field, :default, expected) unless field.key?(:default) &&
|
|
415
|
+
option_mismatch(field, :default, expected) unless field.key?(:default) && same_default?(field, expected)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# A :json default is stored as authored, but the action reads it as
|
|
419
|
+
# a HashWithIndifferentAccess, String-keyed all the way down. The
|
|
420
|
+
# declaration's spelling, `with_default(theme: "dark")`, and the
|
|
421
|
+
# action's, `with_default("theme" => "dark")`, name the same default,
|
|
422
|
+
# so both sides are read that way before comparing.
|
|
423
|
+
def same_default?(field, expected)
|
|
424
|
+
declared = field[:default]
|
|
425
|
+
if field[:kind] == :json && declared.is_a?(Hash) && expected.is_a?(Hash)
|
|
426
|
+
ActiveSupport::HashWithIndifferentAccess.new(declared) == ActiveSupport::HashWithIndifferentAccess.new(expected)
|
|
427
|
+
else
|
|
428
|
+
declared == cast_default(field, expected)
|
|
429
|
+
end
|
|
378
430
|
end
|
|
379
431
|
|
|
380
432
|
# A contract stores its `default:` as the field reads it — cast,
|
|
@@ -388,7 +440,11 @@ module Permittable
|
|
|
388
440
|
# (see validate_authored_value!/validate_array_authored_value!), so
|
|
389
441
|
# `expected` is compared bare, not cast — the matcher would otherwise
|
|
390
442
|
# compare a cast value against an uncast stored one and never match.
|
|
443
|
+
# A :json default is the exception: it is always stored as the walker
|
|
444
|
+
# reads the hash (indifferent, and never cast), so `expected` is read
|
|
445
|
+
# the same way, and `{ theme: "dark" }` matches `{ "theme" => "dark" }`.
|
|
391
446
|
def cast_default(field, expected)
|
|
447
|
+
return cast_json_default(field, expected) if field[:kind] == :json
|
|
392
448
|
return expected if field[:transform]
|
|
393
449
|
|
|
394
450
|
case field[:kind]
|
|
@@ -408,6 +464,13 @@ module Permittable
|
|
|
408
464
|
end
|
|
409
465
|
end
|
|
410
466
|
|
|
467
|
+
def cast_json_default(field, expected)
|
|
468
|
+
return expected unless expected.is_a?(Hash)
|
|
469
|
+
|
|
470
|
+
status, value = AuthoredValues.read_json(field, expected)
|
|
471
|
+
status == :ok ? value : expected
|
|
472
|
+
end
|
|
473
|
+
|
|
411
474
|
# A contract stores an `in:` list cast by the field's type, so
|
|
412
475
|
# `within(%i[draft published])` — the declaration repeated as written —
|
|
413
476
|
# is read the same way before comparing, by the same two functions the
|
|
@@ -483,8 +546,11 @@ module Permittable
|
|
|
483
546
|
end
|
|
484
547
|
end
|
|
485
548
|
|
|
549
|
+
# An anonymous controller class has no name, and a Contract instance
|
|
550
|
+
# never does, so the fallback names the kind of subject it is.
|
|
486
551
|
def subject_name
|
|
487
|
-
(@subject.respond_to?(:name) && @subject.name) ||
|
|
552
|
+
(@subject.respond_to?(:name) && @subject.name) ||
|
|
553
|
+
(@subject.is_a?(Permittable::Contract) ? "the contract" : "the controller")
|
|
488
554
|
end
|
|
489
555
|
|
|
490
556
|
def path_label
|
|
@@ -520,14 +586,28 @@ module Permittable
|
|
|
520
586
|
end
|
|
521
587
|
|
|
522
588
|
# accept_params only: assert the cast, defaulted, transformed output.
|
|
589
|
+
# Refused on reject_params rather than ignored: rejected_ok? never
|
|
590
|
+
# reads it, so the chain would pass while its description claims an
|
|
591
|
+
# assertion that was never made.
|
|
523
592
|
def returning(hash)
|
|
593
|
+
unless @expect_accepted
|
|
594
|
+
raise ArgumentError, "#{LABEL}: `returning` belongs to accept_params, not reject_params — " \
|
|
595
|
+
"a rejected payload returns nothing to compare"
|
|
596
|
+
end
|
|
597
|
+
|
|
524
598
|
@returning = hash
|
|
525
599
|
self
|
|
526
600
|
end
|
|
527
601
|
|
|
528
602
|
# reject_params only: assert a particular violation is among those
|
|
529
|
-
# recorded. Repeatable; the code is optional.
|
|
603
|
+
# recorded. Repeatable; the code is optional. Refused on accept_params
|
|
604
|
+
# for the same reason returning is on reject_params.
|
|
530
605
|
def with_violation(param, code = nil)
|
|
606
|
+
if @expect_accepted
|
|
607
|
+
raise ArgumentError, "#{LABEL}: `with_violation` belongs to reject_params, not accept_params — " \
|
|
608
|
+
"an accepted payload has no violations"
|
|
609
|
+
end
|
|
610
|
+
|
|
531
611
|
@expected_violations << { param: param.to_s, code: code&.to_s }
|
|
532
612
|
self
|
|
533
613
|
end
|
|
@@ -535,14 +615,30 @@ module Permittable
|
|
|
535
615
|
# -- RSpec protocol -----------------------------------------------------
|
|
536
616
|
|
|
537
617
|
def matches?(subject)
|
|
538
|
-
|
|
539
|
-
rule = resolve_rule
|
|
618
|
+
rule = locate(subject)
|
|
540
619
|
return false unless rule
|
|
541
620
|
|
|
542
621
|
@violations, @result = run(rule)
|
|
543
622
|
@expect_accepted ? accepted_ok? : rejected_ok?
|
|
544
623
|
end
|
|
545
624
|
|
|
625
|
+
# Defined explicitly rather than left to RSpec's `!matches?`: an
|
|
626
|
+
# unresolved rule makes matches? false, which a negation would read
|
|
627
|
+
# as a pass — `not_to accept_params(...).for_action(:craete)` passing
|
|
628
|
+
# on a typo, or on a controller that declares nothing at all. A
|
|
629
|
+
# qualifier is refused for the same reason permit_param refuses one:
|
|
630
|
+
# `not_to ...returning(x)` would pass both on a rejection and on an
|
|
631
|
+
# acceptance returning anything else.
|
|
632
|
+
def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
|
|
633
|
+
raise ArgumentError, negated_qualifier_message if @returning || !@expected_violations.empty?
|
|
634
|
+
|
|
635
|
+
rule = locate(subject)
|
|
636
|
+
return false unless rule
|
|
637
|
+
|
|
638
|
+
@violations, @result = run(rule)
|
|
639
|
+
@expect_accepted ? !accepted_ok? : !rejected_ok?
|
|
640
|
+
end
|
|
641
|
+
|
|
546
642
|
def failure_message
|
|
547
643
|
return "expected #{subject_name} to #{description}, but it #{@problem}" if @problem
|
|
548
644
|
|
|
@@ -559,6 +655,8 @@ module Permittable
|
|
|
559
655
|
|
|
560
656
|
def failure_message_when_negated
|
|
561
657
|
verb = @expect_accepted ? "accept" : "reject"
|
|
658
|
+
return "expected #{subject_name} not to #{verb} those params#{action_label}, but it #{@problem}" if @problem
|
|
659
|
+
|
|
562
660
|
"expected #{subject_name} not to #{verb} those params#{action_label}, but it did"
|
|
563
661
|
end
|
|
564
662
|
|
|
@@ -576,6 +674,28 @@ module Permittable
|
|
|
576
674
|
|
|
577
675
|
private
|
|
578
676
|
|
|
677
|
+
# Resolves the subject and its rule, resetting every per-run ivar
|
|
678
|
+
# first so a matcher object reused across examples (or re-chained
|
|
679
|
+
# with another for_action) never reports a previous run's problem.
|
|
680
|
+
def locate(subject)
|
|
681
|
+
@problem = @violations = @result = nil
|
|
682
|
+
@subject = resolve_subject(subject)
|
|
683
|
+
resolve_rule
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def negated_qualifier_message
|
|
687
|
+
call = @expect_accepted ? "accept_params" : "reject_params"
|
|
688
|
+
qualifiers = []
|
|
689
|
+
qualifiers << "returning #{@returning.inspect}" if @returning
|
|
690
|
+
qualifiers << "with_violation #{summary(@expected_violations)}" unless @expected_violations.empty?
|
|
691
|
+
"#{LABEL}: `not_to #{call}(...)` cannot take qualifiers (here: #{qualifiers.join(', ')}) — " \
|
|
692
|
+
"negating one is ambiguous, since it would pass both when the params are " \
|
|
693
|
+
"#{@expect_accepted ? 'rejected' : 'accepted'} and when they are #{@expect_accepted ? 'accepted' : 'rejected'} " \
|
|
694
|
+
"differently. Assert the outcome with the positive form " \
|
|
695
|
+
"(#{@expect_accepted ? 'reject_params' : 'accept_params'}(...), or #{call}(...) with the qualifier), " \
|
|
696
|
+
"or drop the qualifier to assert only that the params are not #{@expect_accepted ? 'accepted' : 'rejected'}."
|
|
697
|
+
end
|
|
698
|
+
|
|
579
699
|
def accepted_ok?
|
|
580
700
|
return false unless @violations.empty?
|
|
581
701
|
|
|
@@ -601,38 +721,80 @@ module Permittable
|
|
|
601
721
|
# of @subject's own class: @subject may be a real controller, and
|
|
602
722
|
# instantiating one outside the framework's own dispatch (no request,
|
|
603
723
|
# no response, whatever else its own before_actions assume) is not
|
|
604
|
-
# safe in general.
|
|
605
|
-
#
|
|
606
|
-
#
|
|
607
|
-
#
|
|
608
|
-
#
|
|
609
|
-
#
|
|
610
|
-
#
|
|
611
|
-
#
|
|
724
|
+
# safe in general.
|
|
725
|
+
#
|
|
726
|
+
# A standalone Permittable::Contract needs no stand-in: it always
|
|
727
|
+
# enforces, and #call is what its callers run, so the matcher runs
|
|
728
|
+
# exactly that — its input handling, its host's overrides (no
|
|
729
|
+
# router-key exemption from unknown:, its instrumentation label) and
|
|
730
|
+
# anything #call grows later. A stand-in skipped Contract's
|
|
731
|
+
# normalize_input: an Array or a String, which #call refuses with
|
|
732
|
+
# ArgumentError, read as {} here, so accept_params passed on an
|
|
733
|
+
# all-optional contract and reject_params(...).with_violation(:missing)
|
|
734
|
+
# on a required one.
|
|
735
|
+
#
|
|
736
|
+
# A controller's own CSRF parameter name is mirrored onto the host too
|
|
737
|
+
# (mirror_csrf_key), since the unknown: check reads it off the live
|
|
738
|
+
# controller.
|
|
612
739
|
def run(rule)
|
|
740
|
+
return run_contract if @subject.is_a?(Permittable::Contract)
|
|
741
|
+
|
|
613
742
|
host = Class.new do
|
|
614
743
|
include Permittable
|
|
615
744
|
|
|
616
745
|
attr_accessor :params
|
|
617
746
|
end
|
|
618
|
-
|
|
747
|
+
action = @action || rule[:actions].first || "call"
|
|
748
|
+
label_instrumentation(host, action)
|
|
749
|
+
mirror_csrf_key(host)
|
|
619
750
|
host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
|
|
620
751
|
instance = host.new
|
|
621
752
|
instance.params = @params
|
|
622
|
-
action = @action || rule[:actions].first || "call"
|
|
623
753
|
violations = instance.permittable_violations(action)
|
|
624
754
|
result = violations.empty? ? instance.permitted_params(action) : nil
|
|
625
755
|
[violations, result]
|
|
626
756
|
end
|
|
627
757
|
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
758
|
+
# A rejection instruments invalid_parameters.permittable exactly as a
|
|
759
|
+
# request would. The host is anonymous, so without this the payload
|
|
760
|
+
# carried controller: nil, action: nil — a subscriber following the
|
|
761
|
+
# README's shape (`payload[:controller].tr("/", ".")`) raised out of
|
|
762
|
+
# the matcher. Label it the way the subject itself would: a
|
|
763
|
+
# controller by its path (or name, or "the contract" when anonymous —
|
|
764
|
+
# an anonymous controller's controller_path is nil), a Contract as
|
|
765
|
+
# Contract#call does.
|
|
766
|
+
def label_instrumentation(host, action)
|
|
767
|
+
label = if @subject.is_a?(Permittable::Contract) then "Permittable::Contract"
|
|
768
|
+
elsif @subject.respond_to?(:controller_path) then @subject.controller_path || subject_name
|
|
769
|
+
else subject_name
|
|
770
|
+
end
|
|
771
|
+
host.define_method(:permittable_controller_name) { label }
|
|
772
|
+
host.define_method(:permittable_action_name) { action }
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def run_contract
|
|
776
|
+
result = @subject.call(@params)
|
|
777
|
+
[result.violations, result.params]
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# A controller exempts its configured CSRF parameter from unknown:
|
|
781
|
+
# :error (permittable_configured_csrf_key, which asks the live
|
|
782
|
+
# controller for `request_forgery_protection_token`). The bare host has
|
|
783
|
+
# no such method, so without this the matcher reported the app's
|
|
784
|
+
# renamed token (`self.request_forgery_protection_token = :csrf_token`)
|
|
785
|
+
# as unknown while the same POST is a 200 at runtime. The setting is
|
|
786
|
+
# class-level, so it is read off the subject class and answered from
|
|
787
|
+
# the host. A standalone Contract has no such method and keeps
|
|
788
|
+
# exempting nothing, like Contract#call.
|
|
789
|
+
#
|
|
790
|
+
# Route path parameters cannot be mirrored the same way: the matcher
|
|
791
|
+
# has no route to read them from (see the README).
|
|
792
|
+
def mirror_csrf_key(host)
|
|
793
|
+
return unless @subject.respond_to?(:request_forgery_protection_token)
|
|
794
|
+
|
|
795
|
+
token = @subject.request_forgery_protection_token
|
|
796
|
+
key = token && token.to_s
|
|
797
|
+
host.define_method(:permittable_configured_csrf_key) { key }
|
|
636
798
|
end
|
|
637
799
|
|
|
638
800
|
def resolve_rule
|
|
@@ -666,8 +828,11 @@ module Permittable
|
|
|
666
828
|
violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
|
|
667
829
|
end
|
|
668
830
|
|
|
831
|
+
# An anonymous controller class has no name, and a Contract instance
|
|
832
|
+
# never does, so the fallback names the kind of subject it is.
|
|
669
833
|
def subject_name
|
|
670
|
-
(@subject.respond_to?(:name) && @subject.name) ||
|
|
834
|
+
(@subject.respond_to?(:name) && @subject.name) ||
|
|
835
|
+
(@subject.is_a?(Permittable::Contract) ? "the contract" : "the controller")
|
|
671
836
|
end
|
|
672
837
|
|
|
673
838
|
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
|
|
21
|
-
#
|
|
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
|
data/lib/permittable/version.rb
CHANGED