permittable 0.1.2 → 0.3.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 +20 -0
- data/README.md +417 -46
- data/lib/permittable/json_schema.rb +196 -0
- data/lib/permittable/open_api.rb +212 -0
- data/lib/permittable/railtie.rb +4 -0
- data/lib/permittable/tasks/openapi.rake +44 -0
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +98 -34
- metadata +5 -2
data/lib/permittable.rb
CHANGED
|
@@ -79,6 +79,15 @@ require "permittable/filter_parameter_registry"
|
|
|
79
79
|
# renders 400, field violations 422. Every violation also instruments
|
|
80
80
|
# "invalid_parameters.permittable" so failures can be dashboarded.
|
|
81
81
|
#
|
|
82
|
+
# Violation MESSAGES stay machine-first (the code is the contract), but a
|
|
83
|
+
# field can attach human-readable copy with `message:` — one String for every
|
|
84
|
+
# code (`message: "must be a valid email"`) or a Hash per code
|
|
85
|
+
# (`message: { missing: "is required", format: "must be a valid email" }`).
|
|
86
|
+
# A resolved message rides into the detail entry as `message:` and replaces
|
|
87
|
+
# the "(code)" rendering in the exception's summary line; codes without a
|
|
88
|
+
# message keep the bare shape, so nothing changes for contracts that don't
|
|
89
|
+
# opt in. `violate!` in finalize accepts the same via `message:`.
|
|
90
|
+
#
|
|
82
91
|
# `sensitive: true` registers the field name with
|
|
83
92
|
# Permittable.filter_parameter_registry (swappable — a host gem can point it
|
|
84
93
|
# at its own registry), consulted at filter time by the proc
|
|
@@ -304,9 +313,9 @@ module Permittable
|
|
|
304
313
|
# declaration is validated eagerly: a bad contract is a programmer error and
|
|
305
314
|
# should fail at class load, not at request time.
|
|
306
315
|
class ContractBuilder
|
|
307
|
-
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform].freeze
|
|
308
|
-
NESTED_OPTS = %i[virtual sensitive].freeze
|
|
309
|
-
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform].freeze
|
|
316
|
+
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example].freeze
|
|
317
|
+
NESTED_OPTS = %i[virtual sensitive message desc].freeze
|
|
318
|
+
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example].freeze
|
|
310
319
|
|
|
311
320
|
attr_reader :finalizer
|
|
312
321
|
|
|
@@ -359,7 +368,9 @@ module Permittable
|
|
|
359
368
|
validate_length!(name, field[:length]) if field.key?(:length)
|
|
360
369
|
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
361
370
|
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
362
|
-
|
|
371
|
+
validate_array_authored_value!(field, :default) if field.key?(:default)
|
|
372
|
+
validate_array_authored_value!(field, :example) if field.key?(:example)
|
|
373
|
+
validate_message!(field)
|
|
363
374
|
@fields << field
|
|
364
375
|
end
|
|
365
376
|
|
|
@@ -371,15 +382,16 @@ module Permittable
|
|
|
371
382
|
raise ArgumentError, "#{LABEL}: :#{name} takes a type OR a nested block, not both" if type
|
|
372
383
|
|
|
373
384
|
assert_opts!(name, opts, NESTED_OPTS)
|
|
374
|
-
|
|
375
|
-
|
|
385
|
+
field = { name: name, kind: :nested, required: required,
|
|
386
|
+
fields: nested_fields!(name, &block), **opts }
|
|
387
|
+
validate_message!(field)
|
|
376
388
|
else
|
|
377
389
|
assert_opts!(name, opts, SCALAR_OPTS)
|
|
378
390
|
field = { name: name, kind: :scalar, required: required,
|
|
379
391
|
type: scalar_type!(name, type || :string), **opts }
|
|
380
392
|
validate_scalar_opts!(field)
|
|
381
|
-
@fields << field
|
|
382
393
|
end
|
|
394
|
+
@fields << field
|
|
383
395
|
end
|
|
384
396
|
|
|
385
397
|
def field_name!(name)
|
|
@@ -431,7 +443,9 @@ module Permittable
|
|
|
431
443
|
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
432
444
|
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
433
445
|
resolve_normalizer!(field)
|
|
434
|
-
|
|
446
|
+
validate_authored_value!(field, :default)
|
|
447
|
+
validate_authored_value!(field, :example)
|
|
448
|
+
validate_message!(field)
|
|
435
449
|
end
|
|
436
450
|
|
|
437
451
|
# format / length / normalize reason about characters; on any other
|
|
@@ -469,28 +483,49 @@ module Permittable
|
|
|
469
483
|
end
|
|
470
484
|
end
|
|
471
485
|
|
|
472
|
-
#
|
|
473
|
-
#
|
|
474
|
-
|
|
475
|
-
|
|
486
|
+
# An authored value (`default:`, or a documentation `example:`) must
|
|
487
|
+
# satisfy the field's own contract — catching a lie at class load beats
|
|
488
|
+
# shipping it to every request (or publishing it in generated docs).
|
|
489
|
+
def validate_authored_value!(field, opt)
|
|
490
|
+
return unless field.key?(opt)
|
|
476
491
|
|
|
477
|
-
status, code = Coercion.check_scalar(field, field[
|
|
492
|
+
status, code = Coercion.check_scalar(field, field[opt])
|
|
478
493
|
return if status == :ok
|
|
479
494
|
|
|
480
|
-
raise ArgumentError, "#{LABEL}:
|
|
495
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
|
|
481
496
|
end
|
|
482
497
|
|
|
483
|
-
def
|
|
484
|
-
|
|
485
|
-
raise ArgumentError, "#{LABEL}:
|
|
498
|
+
def validate_array_authored_value!(field, opt)
|
|
499
|
+
value = field[opt]
|
|
500
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
486
501
|
return unless field[:of]
|
|
487
502
|
|
|
488
|
-
|
|
503
|
+
value.each do |element|
|
|
489
504
|
status, code = Coercion.cast(field[:of], element)
|
|
490
505
|
next if status == :ok
|
|
491
506
|
|
|
492
|
-
raise ArgumentError, "#{LABEL}:
|
|
507
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# `message:` customizes what the client reads for a violation on this
|
|
512
|
+
# field: one String covering every code, or a Hash of code => String
|
|
513
|
+
# (codes without an entry keep the default rendering). Keys are
|
|
514
|
+
# normalized to Symbols here so request-time resolution is a plain
|
|
515
|
+
# lookup.
|
|
516
|
+
def validate_message!(field)
|
|
517
|
+
spec = field[:message]
|
|
518
|
+
return if spec.nil?
|
|
519
|
+
return if spec.is_a?(String)
|
|
520
|
+
|
|
521
|
+
valid_hash = spec.is_a?(Hash) && !spec.empty? &&
|
|
522
|
+
spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
|
|
523
|
+
unless valid_hash
|
|
524
|
+
raise ArgumentError, "#{LABEL}: :message for field :#{field[:name]} must be a String " \
|
|
525
|
+
"or a Hash of violation code => String (e.g. { missing: \"is required\" })"
|
|
493
526
|
end
|
|
527
|
+
|
|
528
|
+
field[:message] = spec.transform_keys(&:to_sym).freeze
|
|
494
529
|
end
|
|
495
530
|
end
|
|
496
531
|
|
|
@@ -504,9 +539,13 @@ module Permittable
|
|
|
504
539
|
|
|
505
540
|
# Records ONE violation and halts the finalize block immediately (the
|
|
506
541
|
# code after a violate! call never runs, so it can assume the checked
|
|
507
|
-
# invariant). The contract then fails as a normal 422.
|
|
508
|
-
|
|
509
|
-
|
|
542
|
+
# invariant). The contract then fails as a normal 422. An optional
|
|
543
|
+
# message: rides along into the violation detail, same as a field's
|
|
544
|
+
# `message:` option.
|
|
545
|
+
def violate!(param, code, message: nil)
|
|
546
|
+
entry = { param: param.to_s, code: code.to_s }
|
|
547
|
+
entry[:message] = message.to_s if message
|
|
548
|
+
@violations << entry
|
|
510
549
|
throw :permittable_finalize_halt
|
|
511
550
|
end
|
|
512
551
|
end
|
|
@@ -525,7 +564,9 @@ module Permittable
|
|
|
525
564
|
# undeclared keys, at every nesting level.
|
|
526
565
|
# enforce: false (default) validates lazily on the first
|
|
527
566
|
# permitted_params call; true validates in a before_action.
|
|
528
|
-
|
|
567
|
+
# desc: documentation only — carried on the rule for exporters
|
|
568
|
+
# (Permittable::OpenAPI); the runtime never reads it.
|
|
569
|
+
def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &block)
|
|
529
570
|
raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
|
|
530
571
|
|
|
531
572
|
unknown = unknown.to_sym
|
|
@@ -541,7 +582,7 @@ module Permittable
|
|
|
541
582
|
|
|
542
583
|
rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
|
|
543
584
|
model: model_class, unknown: unknown, enforce: !!enforce, fields: fields,
|
|
544
|
-
finalize: builder.finalizer }.freeze
|
|
585
|
+
finalize: builder.finalizer, desc: desc }.freeze
|
|
545
586
|
self.permittable_contracts = permittable_contracts + [rule]
|
|
546
587
|
end
|
|
547
588
|
|
|
@@ -663,10 +704,27 @@ module Permittable
|
|
|
663
704
|
"invalid_parameters.permittable",
|
|
664
705
|
controller: permittable_controller_name, action: permittable_action_name, details: violations
|
|
665
706
|
)
|
|
666
|
-
summary = violations.map { |v| "#{v[:param]} (#{v[:code]})" }.join(", ")
|
|
707
|
+
summary = violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
|
|
667
708
|
raise InvalidParameters.new("Invalid parameters: #{summary}", details: violations, status: status)
|
|
668
709
|
end
|
|
669
710
|
|
|
711
|
+
# One violation detail entry. A field's `message:` (String, or Hash keyed
|
|
712
|
+
# by code) attaches a human-readable message; entries without one keep
|
|
713
|
+
# the bare { param:, code: } shape, so existing consumers see no change.
|
|
714
|
+
def permittable_violation(field, param, code)
|
|
715
|
+
entry = { param: param, code: code.to_s }
|
|
716
|
+
message = permittable_message_for(field, code)
|
|
717
|
+
entry[:message] = message if message
|
|
718
|
+
entry
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def permittable_message_for(field, code)
|
|
722
|
+
spec = field[:message]
|
|
723
|
+
return spec if spec.nil? || spec.is_a?(String)
|
|
724
|
+
|
|
725
|
+
spec[code.to_sym]
|
|
726
|
+
end
|
|
727
|
+
|
|
670
728
|
def permittable_run_finalize(finalizer, result, violations)
|
|
671
729
|
runner = FinalizeRunner.new(violations)
|
|
672
730
|
finalized = catch(:permittable_finalize_halt) do
|
|
@@ -713,7 +771,7 @@ module Permittable
|
|
|
713
771
|
if field.key?(:default)
|
|
714
772
|
result[key] = field[:default]
|
|
715
773
|
elsif field[:required]
|
|
716
|
-
violations <<
|
|
774
|
+
violations << permittable_violation(field, full, "missing")
|
|
717
775
|
end
|
|
718
776
|
next
|
|
719
777
|
end
|
|
@@ -733,33 +791,33 @@ module Permittable
|
|
|
733
791
|
out = field[:transform].call(out) if field[:transform]
|
|
734
792
|
result[key] = out
|
|
735
793
|
else
|
|
736
|
-
violations <<
|
|
794
|
+
violations << permittable_violation(field, full, out)
|
|
737
795
|
end
|
|
738
796
|
when :nested
|
|
739
797
|
if value.is_a?(Hash)
|
|
740
798
|
result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
|
|
741
799
|
path: full, unknown: unknown, top_level: false, violations: violations)
|
|
742
800
|
else
|
|
743
|
-
violations <<
|
|
801
|
+
violations << permittable_violation(field, full, "invalid_type")
|
|
744
802
|
end
|
|
745
803
|
when :array
|
|
746
804
|
if value.is_a?(Array)
|
|
747
805
|
result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
|
|
748
806
|
else
|
|
749
|
-
violations <<
|
|
807
|
+
violations << permittable_violation(field, full, "invalid_type")
|
|
750
808
|
end
|
|
751
809
|
end
|
|
752
810
|
end
|
|
753
811
|
|
|
754
812
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
755
813
|
before = violations.length
|
|
756
|
-
violations <<
|
|
814
|
+
violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
757
815
|
out = value.each_with_index.map do |element, index|
|
|
758
816
|
permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
|
|
759
817
|
end
|
|
760
818
|
if field[:validate]
|
|
761
819
|
status, code = Coercion.check_custom(field[:validate], out)
|
|
762
|
-
violations <<
|
|
820
|
+
violations << permittable_violation(field, path, code) unless status == :ok
|
|
763
821
|
end
|
|
764
822
|
# Transform only a fully-valid array — a partially-nil one (element
|
|
765
823
|
# violations) would hand user code garbage it never agreed to see.
|
|
@@ -770,7 +828,7 @@ module Permittable
|
|
|
770
828
|
def permittable_check_element(field, element, path, unknown:, violations:)
|
|
771
829
|
if field[:fields]
|
|
772
830
|
unless element.is_a?(Hash)
|
|
773
|
-
violations <<
|
|
831
|
+
violations << permittable_violation(field, path, "invalid_type")
|
|
774
832
|
return nil
|
|
775
833
|
end
|
|
776
834
|
return permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(element),
|
|
@@ -780,7 +838,7 @@ module Permittable
|
|
|
780
838
|
status, out = Coercion.cast(field[:of], element)
|
|
781
839
|
return out if status == :ok
|
|
782
840
|
|
|
783
|
-
violations <<
|
|
841
|
+
violations << permittable_violation(field, path, out)
|
|
784
842
|
nil
|
|
785
843
|
end
|
|
786
844
|
|
|
@@ -820,5 +878,11 @@ module Permittable
|
|
|
820
878
|
end
|
|
821
879
|
end
|
|
822
880
|
|
|
823
|
-
#
|
|
881
|
+
# Contract exporters — the other readers of the frozen contract registry.
|
|
882
|
+
# Loaded after the module body so OpenAPI can see the concern's own methods.
|
|
883
|
+
require "permittable/json_schema"
|
|
884
|
+
require "permittable/open_api"
|
|
885
|
+
|
|
886
|
+
# Boot-time integration (filter_parameters registration, the
|
|
887
|
+
# permittable:openapi rake task), Rails apps only
|
|
824
888
|
require "permittable/railtie" if defined?(Rails::Railtie)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: permittable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -50,7 +50,10 @@ files:
|
|
|
50
50
|
- lib/permittable/column_guard.rb
|
|
51
51
|
- lib/permittable/error_envelope.rb
|
|
52
52
|
- lib/permittable/filter_parameter_registry.rb
|
|
53
|
+
- lib/permittable/json_schema.rb
|
|
54
|
+
- lib/permittable/open_api.rb
|
|
53
55
|
- lib/permittable/railtie.rb
|
|
56
|
+
- lib/permittable/tasks/openapi.rake
|
|
54
57
|
- lib/permittable/version.rb
|
|
55
58
|
homepage: https://github.com/VSN2015/permittable
|
|
56
59
|
licenses:
|