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.
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
- validate_array_default!(field) if field.key?(:default)
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
- @fields << { name: name, kind: :nested, required: required,
375
- fields: nested_fields!(name, &block), **opts }
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
- validate_default!(field)
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
- # A default must satisfy the field's own contract — catching a bad
473
- # default at class load beats shipping it to every request.
474
- def validate_default!(field)
475
- return unless field.key?(:default)
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[:default])
492
+ status, code = Coercion.check_scalar(field, field[opt])
478
493
  return if status == :ok
479
494
 
480
- raise ArgumentError, "#{LABEL}: :default for field :#{field[:name]} violates its own contract (#{code})"
495
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
481
496
  end
482
497
 
483
- def validate_array_default!(field)
484
- default = field[:default]
485
- raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} must be an Array" unless default.is_a?(Array)
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
- default.each do |element|
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}: :default for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
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
- def violate!(param, code)
509
- @violations << { param: param.to_s, code: code.to_s }
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
- def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &block)
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 << { param: full, code: "missing" }
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 << { param: full, code: out }
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 << { param: full, code: "invalid_type" }
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 << { param: full, code: "invalid_type" }
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 << { param: path, code: "length" } if field[:length] && !Coercion.length_ok?(field[:length], value.length)
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 << { param: path, code: code } unless status == :ok
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 << { param: path, code: "invalid_type" }
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 << { param: path, code: out }
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
- # Boot-time integration (filter_parameters registration), Rails apps only
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.1.2
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-08-16 00:00:00.000000000 Z
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: