permittable 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: deed1778579c6d35f58ff18111114b00566ad0d6d2a92db5d4a9bfc3a730c6a6
4
+ data.tar.gz: 57c83b026226096fcc96552d2df3a51577da12e704b5883fb4ba8c1110704433
5
+ SHA512:
6
+ metadata.gz: 1c89bc7b9ff00e4246f0ac9bcc2937b97fb5ba8cdcfda1cbb5308d7d9cff5b4a7410eb48c5a185acd2b8feb82f595f771319edc33badd64ad1155f3d5e691b51
7
+ data.tar.gz: a0087bd69e30d6739a9fc7af090b35cdbc268fbdad7cb57666d3fc71fc140a7b9e5304827e7d1789ff43ccc3f99789b576fbc8340d23595db38712e27abdb0db
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ <!-- CHANGELOG.md -->
2
+
3
+ ## 0.1.0 (2026-08-16)
4
+
5
+ Initial extraction from [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails) (developed there on `feature/permittable` as `ConcernsOnRails::Controllers::Permittable`; concerns_on_rails now depends on this gem and aliases that constant to `::Permittable`).
6
+
7
+ ### Added
8
+ - **`Permittable`** — declarative, typed params contracts for Rails controllers. `permit_params *actions, root:, model:, unknown:, enforce:` declares a per-action contract (repeatable; no actions = catch-all; last matching rule wins; inherited copy-on-write) whose block DSL (`required`/`optional`/`array`, nested blocks) types every field (`:string :integer :float :decimal :boolean :date :datetime`) and validates it (`in:`, `format:`, `length:`, `normalize:` presets/Proc, `default:` — itself contract-checked at class load — and custom `validate:` with symbol violation codes). `permitted_params` returns the cast/validated/defaulted hash (lazy; `enforce: true` moves the check to a before_action); violations raise `Permittable::InvalidParameters`, auto-rescued into a JSON error envelope as 422 (400 for a missing `root:` key) with machine-readable `details:`, and instrument `invalid_parameters.permittable`.
9
+ - **Schema-drift guard**: `model:` (a class, or `true` to infer from `controller_name`) checks every non-`virtual:` scalar field against the model's columns at controller class load — a column dropped by a migration fails the deploy with a copy-paste migration hint, not the request. Skips gracefully when the schema is unreachable.
10
+ - **Strict coercion**: no ActiveModel::Type leniency — `"abc"` is never `0`, `?age[]=1` type confusion is a violation, not a 500. `nil`/`""` are ABSENT (absent optionals omitted, so partial updates never nil-out columns; `default:` fills absence).
11
+ - **Output reshaping**: per-field `transform:` (a callable applied AFTER cast + validation; defaults and absent fields untouched, partially-invalid arrays never transformed) and a once-per-contract `finalize do |p| ... end` (runs only when every field validated, on a bare runner — controller state unreachable — must return the final Hash) with `violate!(param, code)` as the cross-field validation seam. The request's `params` is never mutated.
12
+ - **`sensitive: true`** registers field names with `Permittable.filter_parameter_registry` (swappable, duck-typed), consulted at filter time by the proc `Permittable::Railtie` appends to `config.filter_parameters`.
13
+ - Sole runtime dependency: activesupport (>= 5.0, < 9). actionpack/activerecord are optional integration points.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ethan Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # Permittable
2
+
3
+ **Typed, validated params contracts for Rails controllers — plus a schema-drift guard.**
4
+
5
+ `Permittable` is what strong parameters would be if it also knew types, bounds, defaults, and *why* a request was bad. `params.permit` (and Rails 8's `params.expect`) only answer "which keys may pass"; a Permittable contract additionally **casts** each field, **validates** it, applies **defaults**, reshapes the output, and turns every failure into a machine-readable 422. Because the contract is class-level data rather than code inside the action, it is introspectable — and can be checked against a model's schema at boot.
6
+
7
+ ```ruby
8
+ class UsersController < ApplicationController
9
+ include Permittable
10
+
11
+ permit_params :create, :update, root: :user, model: User do
12
+ required :name, :string, length: 1..80, normalize: :squish
13
+ required :email, :string, format: URI::MailTo::EMAIL_REGEXP, normalize: :email
14
+ optional :age, :integer, in: 18..120
15
+ optional :ssn, :string, sensitive: true # auto-redacted from logs
16
+ optional :plan, :string, in: %w[free pro], default: "free"
17
+ array :tag_names, of: :string, length: 0..10, virtual: true
18
+ optional :address do
19
+ required :city, :string
20
+ optional :zip, :string, format: /\A\d{5}\z/
21
+ end
22
+ end
23
+
24
+ def create
25
+ user = User.create!(permitted_params) # cast, validated, defaulted
26
+ end
27
+ end
28
+ ```
29
+
30
+ A violating request renders:
31
+
32
+ ```json
33
+ { "success": false,
34
+ "error": { "message": "Invalid parameters: user.age (inclusion)",
35
+ "code": "invalid_parameters",
36
+ "details": [{ "param": "user.age", "code": "inclusion" }] } }
37
+ ```
38
+
39
+ ## Installation
40
+
41
+ ```ruby
42
+ gem "permittable"
43
+ ```
44
+
45
+ The only runtime dependency is `activesupport`. `actionpack` (rescue_from / before_action / `ActionController::Parameters`) and `activerecord` (the `model:` schema-drift guard) are optional — every touchpoint is guarded, so your app brings what it already has.
46
+
47
+ ## The schema-drift guard
48
+
49
+ With `model:` (a class, or `true` to infer from the controller name), every non-`virtual:` scalar field is checked against the model's columns **at controller class load**. Production eager-loads controllers, so a column dropped by a migration fails the deploy, not the request:
50
+
51
+ ```
52
+ Permittable: 'nickname' does not exist in the database (table: users).
53
+ Add it with: bin/rails generate migration AddNicknameToUsers nickname:string
54
+ If this parameter is not backed by a column, declare it with virtual: true.
55
+ ```
56
+
57
+ Nested and array fields are implicitly virtual. The check skips gracefully when the schema is unreachable (`db:create`, `assets:precompile`). In CI, one spec running `Rails.application.eager_load!` exercises every contract in the app.
58
+
59
+ ## Configuration
60
+
61
+ ### `permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &contract)`
62
+
63
+ Repeatable; rules are inherited by subclasses copy-on-write. **No positional actions = catch-all**, and **the last matching rule wins** (contracts are configuration overrides).
64
+
65
+ | Option | Default | Meaning |
66
+ |---|---|---|
67
+ | `*actions` | — | Actions the contract covers; **none = catch-all** |
68
+ | `root:` | `false` | Key to unwrap first (`require(:user)` equivalent); missing/non-hash root → **400** |
69
+ | `model:` | `nil` | Model class (or `true` to infer from `controller_name`) enabling the schema-drift guard |
70
+ | `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — undeclared keys, at every nesting level (`controller`/`action`/`format` exempt at top level) |
71
+ | `enforce:` | `false` | `false` = validate lazily on first `permitted_params` call; `true` = validate in a `before_action` |
72
+
73
+ ### Field DSL
74
+
75
+ - `required :name, :type, **opts` / `optional :name, :type, **opts` — type defaults to `:string`; types: `:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`.
76
+ - A block instead of a type declares a **nested hash**; violation paths are dotted (`user.address.zip`).
77
+ - `array :name, of: :type` (or a block for arrays of hashes) — `length:` constrains the element **count**, element failures carry the index (`items[1]`), `required: true` opts in.
78
+
79
+ Per-field options: `in:` (Range/Array), `format:` / `length:` / `normalize:` (`:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc; string fields only), `default:` (validated against the field's own contract at class load), `validate:` (Proc — falsy fails as `"invalid"`, a returned Symbol becomes the violation code), `transform:` (below), `virtual:`, `sensitive:`.
80
+
81
+ Every bad declaration raises a teaching `ArgumentError` at class load.
82
+
83
+ ## Output reshaping (`transform:` / `finalize`)
84
+
85
+ The safe replacement for params-mutating before_actions — both layers operate on the validated **copy**; the request's `params` is never touched.
86
+
87
+ - **`transform:`** (scalar and array fields) — a callable applied **after** cast and validation: `transform: ->(v) { v.split(",") }` turns a validated delimited String into an Array. Absent fields stay absent, `default:` values are authored in final shape, and a partially-invalid array is never transformed.
88
+ - **`finalize do |p| … end`** (once per contract, top level only) — runs after every field validated cleanly, receives the result hash, and must return the final Hash. It executes on a bare runner, **not** the controller, so contracts stay pure; its one extra verb, `violate!(param, code)`, records a violation and halts the block immediately — the cross-field validation seam.
89
+
90
+ ```ruby
91
+ permit_params :create, root: :lease_addendum_form do
92
+ required :resident_signatures, :string, transform: ->(v) { v.split("<<delimiter>>") }
93
+ required :signer_names, :string, transform: ->(v) { v.split(",") }
94
+
95
+ finalize do |p|
96
+ violate!("lease_addendum_form.signer_names", :length_mismatch) unless p[:signer_names].length == p[:resident_signatures].length
97
+ p[:signatures] = p[:resident_signatures].zip(p[:signer_names]).map { |image, name| Signature.new(image:, full_name: name) }
98
+ p.except(:resident_signatures, :signer_names)
99
+ end
100
+ end
101
+ ```
102
+
103
+ ## Methods
104
+
105
+ - `permitted_params(action = action_name)` — the cast/validated/defaulted `HashWithIndifferentAccess`. Absent optional fields are **omitted** (partial updates never nil-out columns). Memoized per action. Raises `Permittable::InvalidParameters` on violation; `ArgumentError` when no contract covers the action (programmer error).
106
+ - `enforce_params_contract` — the `before_action` entry point (skip with `skip_before_action`); only validates rules declared with `enforce: true`.
107
+ - `render_invalid_parameters(error)` — the `rescue_from` target; renders via the host's `render_error` when defined, the identical inline envelope otherwise.
108
+ - Class-side introspection: `permittable_contracts` and `permit_rule_for(action)`.
109
+ - `Permittable.filter_parameter_registry` — duck-typed, swappable sink for `sensitive:` field names; `Permittable::Railtie` appends its live filter proc to `config.filter_parameters`.
110
+
111
+ ## Semantics worth knowing
112
+
113
+ - **Coercion is strict** — deliberately not `ActiveModel::Type` (`"abc".to_i == 0` silently corrupts untrusted input). `"4.5"` is not an integer; booleans accept only `true/false/"true"/"false"/"1"/"0"/1/0`; unparseable dates are `invalid_type`; zoneless datetime strings parse as **UTC**.
114
+ - **Type confusion is a violation, not a 500**: `?age[]=1` where a scalar is declared yields `invalid_type`.
115
+ - `nil` and `""` are both **absent**; boolean `false` is present.
116
+ - Every violation instruments `invalid_parameters.permittable` for dashboards.
117
+ - Used inside [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `include ConcernsOnRails::Controllers::Permittable` is an alias for this module, and `sensitive:` registrations pool into that gem's shared filter registry.
118
+
119
+ ## Development
120
+
121
+ ```sh
122
+ bundle install
123
+ bundle exec rspec
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT.
@@ -0,0 +1,51 @@
1
+ require "active_support/core_ext/string/inflections"
2
+
3
+ module Permittable
4
+ # Schema validation behind the drift guard. When the schema is unreachable —
5
+ # no database yet (`db:create`, a fresh `db:migrate`, `assets:precompile`,
6
+ # CI bootstrap) or the table not yet migrated — the check is skipped and
7
+ # `false` is returned instead of raising, so controller classes stay
8
+ # loadable. A missing column with a *reachable* schema still raises: the
9
+ # rescue is scoped to ActiveRecord::ActiveRecordError precisely so real bugs
10
+ # (NameError from a typo etc.) keep surfacing. Skipping is self-healing:
11
+ # once the migration runs and classes reload, validation happens for real.
12
+ module ColumnGuard
13
+ module_function
14
+
15
+ # `types:` teaches the error message: a Symbol/String applies to every
16
+ # listed field, a Hash maps field => type. The raised ArgumentError then
17
+ # appends a ready-to-paste migration command.
18
+ def ensure_columns_on!(label, klass, *fields, types: nil)
19
+ return false unless schema_reachable?(klass)
20
+
21
+ fields.flatten.compact.each do |field|
22
+ next if klass.column_names.include?(field.to_s)
23
+
24
+ raise ArgumentError,
25
+ "#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
26
+ "#{column_migration_hint(klass, field, types)}"
27
+ end
28
+ true
29
+ end
30
+
31
+ def column_migration_hint(klass, field, types)
32
+ type = types.is_a?(Hash) ? types[field.to_sym] : types
33
+ column = [field, type].compact.join(":")
34
+ " Add it with: bin/rails generate migration " \
35
+ "Add#{field.to_s.camelize}To#{klass.table_name.to_s.camelize} #{column}"
36
+ end
37
+
38
+ # True when the class's table can actually be inspected. Connection errors
39
+ # (ConnectionNotEstablished, NoDatabaseError, adapter errors) all inherit
40
+ # from ActiveRecord::ActiveRecordError; the defined? guard keeps this gem
41
+ # loadable without activerecord (a host without it cannot pass `model:`
42
+ # anyway).
43
+ def schema_reachable?(klass)
44
+ klass.table_exists?
45
+ rescue StandardError => e
46
+ raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
47
+
48
+ false
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,25 @@
1
+ module Permittable
2
+ # One home for the error envelope: prefer the host's #render_error when the
3
+ # controller defines one (e.g. concerns_on_rails' Respondable), otherwise
4
+ # render the identical inline shape — and the single place to change when
5
+ # e.g. an RFC 9457 problem+json mode lands.
6
+ module ErrorEnvelope
7
+ module_function
8
+
9
+ def render(controller, message:, status:, code: nil, details: nil)
10
+ if controller.respond_to?(:render_error)
11
+ # errors: only when there are details — a host may document its
12
+ # render_error contract as `(message:, status:, code:)`, and an
13
+ # unconditional errors: kwarg would break those implementations.
14
+ kwargs = { message: message, code: code, status: status }
15
+ kwargs[:errors] = details if details
16
+ controller.render_error(**kwargs)
17
+ else
18
+ error = { message: message }
19
+ error[:code] = code if code
20
+ error[:details] = details if details
21
+ controller.render(json: { success: false, error: error }, status: status)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,68 @@
1
+ require "set"
2
+
3
+ module Permittable
4
+ # Registry of sensitive parameter names (populated by `sensitive: true`
5
+ # contract fields) surfaced to Rails' log filtering. Appending plain symbols
6
+ # to `config.filter_parameters` at class-load time misses every consumer
7
+ # that snapshots the list at boot (ActiveRecord's `filter_attributes` copy,
8
+ # lograge-style initializers, precompiled filters). A proc appended once at
9
+ # boot by Permittable::Railtie consults this live registry at *filter time*,
10
+ # so fields registered when a controller class loads later (lazy loading in
11
+ # development) are still redacted.
12
+ #
13
+ # Matching mirrors Rails symbol-filter semantics: case-insensitive substring
14
+ # match on the parameter key. The whole object is duck-typed (#add,
15
+ # #include?, #to_proc, #reset!) so a host can swap in its own registry via
16
+ # `Permittable.filter_parameter_registry=` and pool registrations.
17
+ class FilterParameterRegistry
18
+ FILTERED = "[FILTERED]".freeze
19
+
20
+ def initialize
21
+ @fields = Set.new
22
+ @mutex = Mutex.new
23
+ @pattern = nil
24
+ # Stable object so the Railtie's idempotence check (`include?` before
25
+ # `<<`) holds across repeated initializer runs. ActiveSupport's
26
+ # ParameterFilter dups values before invoking proc filters, so in-place
27
+ # String#replace is the supported redaction mechanism.
28
+ @proc = lambda do |key, value|
29
+ value.replace(FILTERED) if value.is_a?(String) && include?(key)
30
+ end
31
+ end
32
+
33
+ def add(field)
34
+ name = field.to_s.downcase
35
+ return if name.empty?
36
+
37
+ @mutex.synchronize do
38
+ @pattern = nil if @fields.add?(name)
39
+ end
40
+ nil
41
+ end
42
+
43
+ def include?(key)
44
+ regexp = pattern
45
+ !regexp.nil? && regexp.match?(key.to_s)
46
+ end
47
+
48
+ def pattern
49
+ @mutex.synchronize do
50
+ next nil if @fields.empty?
51
+
52
+ @pattern ||= Regexp.new(@fields.map { |f| Regexp.escape(f) }.join("|"), Regexp::IGNORECASE)
53
+ end
54
+ end
55
+
56
+ def to_proc
57
+ @proc
58
+ end
59
+
60
+ # Spec hygiene — the registry is process-global.
61
+ def reset!
62
+ @mutex.synchronize do
63
+ @fields.clear
64
+ @pattern = nil
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,16 @@
1
+ require "rails/railtie"
2
+
3
+ module Permittable
4
+ # Boot-time integration, loaded only when Rails is present (see the
5
+ # conditional require at the bottom of lib/permittable.rb): appends the
6
+ # live-registry filter proc before ActiveRecord copies
7
+ # `config.filter_parameters` into `filter_attributes` (a `+=` snapshot), so
8
+ # `sensitive: true` params are redacted from both request logs and #inspect.
9
+ class Railtie < Rails::Railtie
10
+ initializer "permittable.filter_parameters",
11
+ before: "active_record.set_filter_attributes" do |app|
12
+ filter = ::Permittable.filter_parameter_registry.to_proc
13
+ app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ module Permittable
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,830 @@
1
+ require "active_support"
2
+ require "active_support/concern"
3
+ require "active_support/notifications"
4
+ require "active_support/hash_with_indifferent_access"
5
+ require "active_support/core_ext/class/attribute"
6
+ require "active_support/core_ext/string/inflections"
7
+ require "active_support/core_ext/string/filters"
8
+ require "bigdecimal"
9
+ require "date"
10
+ require "time"
11
+
12
+ require "permittable/version"
13
+ require "permittable/error_envelope"
14
+ require "permittable/column_guard"
15
+ require "permittable/filter_parameter_registry"
16
+
17
+ # Declarative, typed params contracts — what strong parameters would be if it
18
+ # also knew types, bounds, defaults, and why a request was bad. Strong
19
+ # parameters (and Rails 8's `params.expect`) only answer "which keys may
20
+ # pass"; a Permittable contract additionally casts each field, validates it,
21
+ # applies defaults, and turns every failure into a machine-readable 422 — and,
22
+ # because the contract is class-level data rather than code inside the action,
23
+ # it is introspectable (`permittable_contracts`) and can be checked against a
24
+ # model's schema at class-load time.
25
+ #
26
+ # class UsersController < ApplicationController
27
+ # include Permittable
28
+ #
29
+ # permit_params :create, :update, root: :user, model: User do
30
+ # required :name, :string, length: 1..80, normalize: :squish
31
+ # required :email, :string, format: URI::MailTo::EMAIL_REGEXP, normalize: :email
32
+ # optional :age, :integer, in: 18..120
33
+ # optional :ssn, :string, sensitive: true
34
+ # optional :plan, :string, in: %w[free pro], default: "free"
35
+ # array :tag_names, of: :string, length: 0..10, virtual: true
36
+ # optional :address do
37
+ # required :city, :string
38
+ # optional :zip, :string, format: /\A\d{5}\z/
39
+ # end
40
+ # end
41
+ #
42
+ # def create
43
+ # user = User.create!(permitted_params) # cast, validated, defaulted
44
+ # end
45
+ # end
46
+ #
47
+ # THE LAST MATCHING RULE WINS: contracts are configuration, so a base
48
+ # controller's catch-all (a rule declared with no actions) is overridden by a
49
+ # later action-specific declaration in a subclass. Rules accumulate via
50
+ # reassignment, never mutation, so subclasses inherit copy-on-write.
51
+ #
52
+ # Schema-drift guard — the reason `model:` exists. Every non-virtual scalar
53
+ # field is checked against the model's columns when the macro runs, i.e. at
54
+ # controller class load. Production eager-loads controllers, so a column
55
+ # dropped by a migration fails the deploy, not the request; the error carries
56
+ # a copy-paste migration hint. Fields not backed by a column
57
+ # (password_confirmation, terms flags) opt out with `virtual: true`; nested
58
+ # and array fields are implicitly virtual. When the schema is unreachable
59
+ # (db:create, assets:precompile) the check skips. In CI, one
60
+ # `Rails.application.eager_load!` spec exercises every contract in the app.
61
+ #
62
+ # Validation is LAZY: it runs on the first `permitted_params` call, so an
63
+ # action that never reads params never pays. `enforce: true` installs the
64
+ # check as a before_action instead (reject before the action body runs).
65
+ #
66
+ # Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
67
+ # casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
68
+ # and silently corrupting untrusted input is exactly what a contract must not
69
+ # do. A value the type cannot faithfully represent is a violation, not a
70
+ # guess. nil and "" are both treated as ABSENT (the query-param convention):
71
+ # absent optional fields are OMITTED from the result (so partial updates never
72
+ # nil-out columns), absent required fields violate, and `default:` fills
73
+ # absence. Clearing a column to NULL is therefore outside a contract's
74
+ # vocabulary — do that explicitly.
75
+ #
76
+ # Failures raise Permittable::InvalidParameters, rescued (on a real
77
+ # controller) into the shared ErrorEnvelope shape with `details:` entries of
78
+ # `{ param: "user.address.zip", code: "format" }`; a missing `root:` key
79
+ # renders 400, field violations 422. Every violation also instruments
80
+ # "invalid_parameters.permittable" so failures can be dashboarded.
81
+ #
82
+ # `sensitive: true` registers the field name with
83
+ # Permittable.filter_parameter_registry (swappable — a host gem can point it
84
+ # at its own registry), consulted at filter time by the proc
85
+ # Permittable::Railtie appends to `config.filter_parameters`.
86
+ #
87
+ # OUTPUT RESHAPING — the safe replacement for params-mutating before_actions.
88
+ # Two layers, both operating on the validated COPY (the request's `params` is
89
+ # never touched):
90
+ # * `transform:` (scalar and array fields) — a callable applied AFTER cast
91
+ # and validation to reshape that field's output, e.g.
92
+ # `transform: ->(v) { v.split(",") }` turns a validated delimited String
93
+ # into an Array. Runs only on request-supplied values: absent fields stay
94
+ # absent and `default:` values are authored in final shape.
95
+ # * `finalize do |p| ... end` (once per contract) — runs after every field
96
+ # validated cleanly, receives the result hash, and must return the
97
+ # (possibly restructured) Hash: combine parallel fields, build value
98
+ # objects, drop scaffolding keys. It executes on a bare runner — NOT the
99
+ # controller — so contracts stay pure data + pure functions; the only
100
+ # extra vocabulary is `violate!(param, code)`, which records one violation
101
+ # and halts the block immediately (the whole contract then fails as a
102
+ # normal 422), making finalize double as the cross-field validation seam
103
+ # ("ends_at after starts_at").
104
+ #
105
+ # Naming note: some legacy stacks (InheritedResources) define their own
106
+ # `permitted_params`; don't include both on one controller.
107
+ module Permittable
108
+ extend ActiveSupport::Concern
109
+
110
+ LABEL = "Permittable".freeze
111
+ SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
112
+ UNKNOWN_MODES = %i[ignore log error].freeze
113
+ # Rails merges routing bookkeeping into params; a top-level (root: false)
114
+ # unknown-keys check must not flag them.
115
+ ROUTING_KEYS = %w[controller action format].freeze
116
+
117
+ NORMALIZERS = {
118
+ squish: ->(v) { v.squish },
119
+ strip: ->(v) { v.strip },
120
+ downcase: ->(v) { v.downcase },
121
+ upcase: ->(v) { v.upcase },
122
+ email: ->(v) { v.strip.downcase },
123
+ }.freeze
124
+
125
+ @registry_mutex = Mutex.new
126
+
127
+ class << self
128
+ # Duck-typed sink for `sensitive:` field names (#add / #include? /
129
+ # #to_proc / #reset!). Swappable so a host gem can pool registrations into
130
+ # its own registry (concerns_on_rails does exactly this).
131
+ def filter_parameter_registry
132
+ @filter_parameter_registry || @registry_mutex.synchronize do
133
+ @filter_parameter_registry ||= FilterParameterRegistry.new
134
+ end
135
+ end
136
+
137
+ attr_writer :filter_parameter_registry
138
+ end
139
+
140
+ # Raised when the request violates the matching contract. `details` is an
141
+ # array of { param:, code: } hashes; `status` is :bad_request for a missing
142
+ # root key, :unprocessable_entity for field violations.
143
+ class InvalidParameters < StandardError
144
+ attr_reader :details, :status
145
+
146
+ def initialize(message, details: [], status: :unprocessable_entity)
147
+ super(message)
148
+ @details = details
149
+ @status = status
150
+ end
151
+ end
152
+
153
+ included do
154
+ class_attribute :permittable_contracts, instance_accessor: false, default: []
155
+
156
+ rescue_from InvalidParameters, with: :render_invalid_parameters if respond_to?(:rescue_from)
157
+ before_action :enforce_params_contract if respond_to?(:before_action)
158
+ end
159
+
160
+ # Strict params-shaped coercion, shared by request-time validation and
161
+ # macro-time `default:` checking. Every entry point returns
162
+ # [:ok, cast_value] or [:error, code_string].
163
+ module Coercion
164
+ module_function
165
+
166
+ TRUE_VALUES = [true, "true", "1", 1].freeze
167
+ FALSE_VALUES = [false, "false", "0", 0].freeze
168
+
169
+ # Full pipeline for one scalar field: normalize → cast → in / format /
170
+ # length / validate.
171
+ def check_scalar(field, value)
172
+ value = apply_normalize(field[:normalize], value)
173
+ status, value = cast(field[:type], value)
174
+ return [status, value] unless status == :ok
175
+
176
+ check_scalar_rules(field, value)
177
+ end
178
+
179
+ def check_scalar_rules(field, value)
180
+ return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
181
+ return [:error, "format"] if field[:format] && !field[:format].match?(value)
182
+ return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
183
+
184
+ check_custom(field[:validate], value)
185
+ end
186
+
187
+ # A custom validator returning a Symbol fails with that symbol as the
188
+ # violation code; false/nil fails as "invalid"; any other truthy value
189
+ # passes.
190
+ def check_custom(validator, value)
191
+ return [:ok, value] unless validator
192
+
193
+ verdict = validator.call(value)
194
+ return [:error, verdict.to_s] if verdict.is_a?(Symbol)
195
+ return [:error, "invalid"] unless verdict
196
+
197
+ [:ok, value]
198
+ end
199
+
200
+ def cast(type, value)
201
+ return [:error, "invalid_type"] unless scalar_shaped?(value)
202
+
203
+ public_send("cast_#{type}", value)
204
+ end
205
+
206
+ # Arrays, hashes, and nested ActionController::Parameters
207
+ # (`?age[]=1`, `?age[x]=1`) can never satisfy a scalar type.
208
+ def scalar_shaped?(value)
209
+ return false if value.is_a?(Array) || value.is_a?(Hash)
210
+ return false if defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
211
+
212
+ true
213
+ end
214
+
215
+ def cast_string(value)
216
+ case value
217
+ when String then [:ok, value]
218
+ when Numeric, true, false then [:ok, value.to_s]
219
+ else [:error, "invalid_type"]
220
+ end
221
+ end
222
+
223
+ def cast_integer(value)
224
+ case value
225
+ when Integer then [:ok, value]
226
+ when Float then value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
227
+ when String then [:ok, Integer(value, 10)]
228
+ else [:error, "invalid_type"]
229
+ end
230
+ rescue ArgumentError
231
+ [:error, "invalid_type"]
232
+ end
233
+
234
+ def cast_float(value)
235
+ case value
236
+ when Numeric then [:ok, value.to_f]
237
+ when String then [:ok, Float(value)]
238
+ else [:error, "invalid_type"]
239
+ end
240
+ rescue ArgumentError
241
+ [:error, "invalid_type"]
242
+ end
243
+
244
+ def cast_decimal(value)
245
+ case value
246
+ when Numeric, String then [:ok, BigDecimal(value.to_s)]
247
+ else [:error, "invalid_type"]
248
+ end
249
+ rescue ArgumentError
250
+ [:error, "invalid_type"]
251
+ end
252
+
253
+ def cast_boolean(value)
254
+ return [:ok, true] if TRUE_VALUES.include?(value)
255
+ return [:ok, false] if FALSE_VALUES.include?(value)
256
+
257
+ [:error, "invalid_type"]
258
+ end
259
+
260
+ def cast_date(value)
261
+ case value
262
+ when Date then [:ok, value]
263
+ when String then [:ok, Date.parse(value)]
264
+ else [:error, "invalid_type"]
265
+ end
266
+ rescue ArgumentError, RangeError
267
+ [:error, "invalid_type"]
268
+ end
269
+
270
+ # A zoneless String parses as UTC regardless of the host timezone
271
+ # (deterministic); explicit offsets are honoured and normalised to UTC.
272
+ def cast_datetime(value)
273
+ case value
274
+ when ActiveSupport::TimeWithZone, Time then [:ok, value.to_time.utc]
275
+ when DateTime then [:ok, value.to_time.utc]
276
+ when Date then [:ok, Time.utc(value.year, value.month, value.day)]
277
+ when String then [:ok, DateTime.parse(value).to_time.utc]
278
+ else [:error, "invalid_type"]
279
+ end
280
+ rescue ArgumentError, RangeError
281
+ [:error, "invalid_type"]
282
+ end
283
+
284
+ # Presets only make sense on String input; a non-String value (JSON
285
+ # numbers, booleans) skips normalization and goes straight to the cast.
286
+ def apply_normalize(normalizer, value)
287
+ return value unless normalizer && value.is_a?(String)
288
+
289
+ normalizer.call(value)
290
+ end
291
+
292
+ # Range#include? walks discrete ranges; cover? is the O(1) bounds check
293
+ # and the right semantics for validation.
294
+ def included_in?(allowed, value)
295
+ allowed.is_a?(Range) ? allowed.cover?(value) : allowed.include?(value)
296
+ end
297
+
298
+ def length_ok?(spec, length)
299
+ spec.is_a?(Range) ? spec.cover?(length) : spec == length
300
+ end
301
+ end
302
+
303
+ # Builds the frozen field list from the permit_params block. Every
304
+ # declaration is validated eagerly: a bad contract is a programmer error and
305
+ # should fail at class load, not at request time.
306
+ 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
310
+
311
+ attr_reader :finalizer
312
+
313
+ def initialize
314
+ @fields = []
315
+ @finalizer = nil
316
+ end
317
+
318
+ def build(&block)
319
+ instance_eval(&block)
320
+ @fields.map(&:freeze).freeze
321
+ end
322
+
323
+ # Post-validation reshaping of the whole contract — see the module
324
+ # comment. Once per contract, top level only.
325
+ def finalize(&block)
326
+ raise ArgumentError, "#{LABEL}: finalize requires a block" unless block
327
+ raise ArgumentError, "#{LABEL}: finalize may only be declared once per contract" if @finalizer
328
+
329
+ @finalizer = block
330
+ end
331
+
332
+ # `required :name` defaults the type to :string. A block instead of a
333
+ # type declares a nested hash of sub-fields.
334
+ def required(name, type = nil, **opts, &block)
335
+ add_field(name, type, required: true, opts: opts, &block)
336
+ end
337
+
338
+ def optional(name, type = nil, **opts, &block)
339
+ add_field(name, type, required: false, opts: opts, &block)
340
+ end
341
+
342
+ # Array of scalars (`of:`, default :string) or, with a block, an array
343
+ # of nested hashes. Optional unless `required: true`; `length:`
344
+ # constrains the element COUNT.
345
+ def array(name, **opts, &block)
346
+ name = field_name!(name)
347
+ assert_opts!(name, opts, ARRAY_OPTS)
348
+ required = opts.delete(:required) ? true : false
349
+
350
+ field = { name: name, kind: :array, required: required, **opts }
351
+ if block
352
+ raise ArgumentError, "#{LABEL}: array :#{name} takes of: OR a block, not both" if opts.key?(:of)
353
+
354
+ field[:fields] = nested_fields!(name, &block)
355
+ field.delete(:of)
356
+ else
357
+ field[:of] = scalar_type!(name, opts[:of] || :string)
358
+ end
359
+ validate_length!(name, field[:length]) if field.key?(:length)
360
+ validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
361
+ validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
362
+ validate_array_default!(field) if field.key?(:default)
363
+ @fields << field
364
+ end
365
+
366
+ private
367
+
368
+ def add_field(name, type, required:, opts:, &block)
369
+ name = field_name!(name)
370
+ if block
371
+ raise ArgumentError, "#{LABEL}: :#{name} takes a type OR a nested block, not both" if type
372
+
373
+ assert_opts!(name, opts, NESTED_OPTS)
374
+ @fields << { name: name, kind: :nested, required: required,
375
+ fields: nested_fields!(name, &block), **opts }
376
+ else
377
+ assert_opts!(name, opts, SCALAR_OPTS)
378
+ field = { name: name, kind: :scalar, required: required,
379
+ type: scalar_type!(name, type || :string), **opts }
380
+ validate_scalar_opts!(field)
381
+ @fields << field
382
+ end
383
+ end
384
+
385
+ def field_name!(name)
386
+ name = name.to_sym
387
+ if @fields.any? { |f| f[:name] == name }
388
+ raise ArgumentError, "#{LABEL}: field :#{name} is declared twice in the same contract"
389
+ end
390
+
391
+ name
392
+ end
393
+
394
+ def assert_opts!(name, opts, allowed)
395
+ unknown = opts.keys - allowed
396
+ return if unknown.empty?
397
+
398
+ raise ArgumentError,
399
+ "#{LABEL}: unknown option(s) #{unknown.map(&:inspect).join(', ')} for field :#{name} " \
400
+ "(allowed: #{allowed.map(&:inspect).join(', ')})"
401
+ end
402
+
403
+ def scalar_type!(name, type)
404
+ type = type.to_sym
405
+ return type if SCALAR_TYPES.include?(type)
406
+
407
+ raise ArgumentError, "#{LABEL}: field :#{name} has unknown type :#{type} " \
408
+ "(supported: #{SCALAR_TYPES.join(', ')})"
409
+ end
410
+
411
+ def nested_fields!(name, &block)
412
+ builder = ContractBuilder.new
413
+ fields = builder.build(&block)
414
+ raise ArgumentError, "#{LABEL}: nested field :#{name} declares no sub-fields" if fields.empty?
415
+ if builder.finalizer
416
+ raise ArgumentError, "#{LABEL}: finalize is only available at the top level of a contract (found inside :#{name})"
417
+ end
418
+
419
+ fields
420
+ end
421
+
422
+ def validate_scalar_opts!(field)
423
+ name = field[:name]
424
+ if field[:required] && field.key?(:default)
425
+ raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
426
+ end
427
+ if field.key?(:in) && !field[:in].respond_to?(:include?)
428
+ raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
429
+ end
430
+
431
+ validate_string_only_opts!(field)
432
+ validate_length!(name, field[:length]) if field.key?(:length)
433
+ validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
434
+ validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
435
+ resolve_normalizer!(field)
436
+ validate_default!(field)
437
+ end
438
+
439
+ # format / length / normalize reason about characters; on any other
440
+ # type they would silently apply to a cast non-String and mislead.
441
+ def validate_string_only_opts!(field)
442
+ return if field[:type] == :string
443
+
444
+ %i[format length normalize].each do |opt|
445
+ next unless field.key?(opt)
446
+
447
+ raise ArgumentError, "#{LABEL}: :#{opt} is only supported on :string fields (field :#{field[:name]} is :#{field[:type]})"
448
+ end
449
+ end
450
+
451
+ def validate_length!(name, length)
452
+ return if length.is_a?(Range) || length.is_a?(Integer)
453
+
454
+ raise ArgumentError, "#{LABEL}: :length for :#{name} must be a Range or Integer"
455
+ end
456
+
457
+ def validate_callable!(name, opt, value)
458
+ return if value.respond_to?(:call)
459
+
460
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{name} must be callable"
461
+ end
462
+
463
+ def resolve_normalizer!(field)
464
+ normalizer = field[:normalize]
465
+ return if normalizer.nil?
466
+ return if normalizer.respond_to?(:call) && !normalizer.is_a?(Symbol)
467
+
468
+ field[:normalize] = NORMALIZERS.fetch(normalizer.to_sym) do
469
+ raise ArgumentError, "#{LABEL}: unknown :normalize preset :#{normalizer} for field :#{field[:name]} " \
470
+ "(presets: #{NORMALIZERS.keys.join(', ')}, or pass a Proc)"
471
+ end
472
+ end
473
+
474
+ # A default must satisfy the field's own contract — catching a bad
475
+ # default at class load beats shipping it to every request.
476
+ def validate_default!(field)
477
+ return unless field.key?(:default)
478
+
479
+ status, code = Coercion.check_scalar(field, field[:default])
480
+ return if status == :ok
481
+
482
+ raise ArgumentError, "#{LABEL}: :default for field :#{field[:name]} violates its own contract (#{code})"
483
+ end
484
+
485
+ def validate_array_default!(field)
486
+ default = field[:default]
487
+ raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} must be an Array" unless default.is_a?(Array)
488
+ return unless field[:of]
489
+
490
+ default.each do |element|
491
+ status, code = Coercion.cast(field[:of], element)
492
+ next if status == :ok
493
+
494
+ raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
495
+ end
496
+ end
497
+ end
498
+
499
+ # The `self` a finalize block runs on. Deliberately bare — no controller
500
+ # delegation — so a contract cannot grow request-state dependencies; its
501
+ # whole vocabulary is the hash it receives plus `violate!`.
502
+ class FinalizeRunner
503
+ def initialize(violations)
504
+ @violations = violations
505
+ end
506
+
507
+ # Records ONE violation and halts the finalize block immediately (the
508
+ # code after a violate! call never runs, so it can assume the checked
509
+ # invariant). The contract then fails as a normal 422.
510
+ def violate!(param, code)
511
+ @violations << { param: param.to_s, code: code.to_s }
512
+ throw :permittable_finalize_halt
513
+ end
514
+ end
515
+
516
+ class_methods do
517
+ # Declare a params contract. No positional actions = catch-all for the
518
+ # whole controller. Repeatable; the LAST rule matching the request's
519
+ # action wins.
520
+ #
521
+ # root: key to unwrap first (`require(:user)` equivalent); false
522
+ # (default) reads top-level params. Missing root renders 400.
523
+ # model: a model class (or `true` to infer from controller_name)
524
+ # enabling the schema-drift check on every non-virtual scalar
525
+ # field.
526
+ # unknown: :ignore (default) / :log / :error — what to do with
527
+ # undeclared keys, at every nesting level.
528
+ # enforce: false (default) validates lazily on the first
529
+ # permitted_params call; true validates in a before_action.
530
+ def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &block)
531
+ raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
532
+
533
+ unknown = unknown.to_sym
534
+ raise ArgumentError, "#{LABEL}: :unknown must be one of #{UNKNOWN_MODES.join(', ')}" unless UNKNOWN_MODES.include?(unknown)
535
+
536
+ builder = ContractBuilder.new
537
+ fields = builder.build(&block)
538
+ raise ArgumentError, "#{LABEL}: a contract must declare at least one field" if fields.empty?
539
+
540
+ model_class = resolve_permit_model(model)
541
+ guard_contract_columns!(model_class, fields) if model_class
542
+ register_sensitive_params(fields)
543
+
544
+ rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
545
+ model: model_class, unknown: unknown, enforce: !!enforce, fields: fields,
546
+ finalize: builder.finalizer }.freeze
547
+ self.permittable_contracts = permittable_contracts + [rule]
548
+ end
549
+
550
+ # The LAST declared rule matching `action`, or nil.
551
+ def permit_rule_for(action)
552
+ action = action.to_s
553
+ permittable_contracts.reverse_each.find do |rule|
554
+ rule[:actions].empty? || rule[:actions].include?(action)
555
+ end
556
+ end
557
+
558
+ private
559
+
560
+ def resolve_permit_model(model)
561
+ case model
562
+ when nil, false then nil
563
+ when true then infer_permit_model
564
+ else
565
+ unless model.is_a?(Class) && model.respond_to?(:column_names)
566
+ raise ArgumentError, "#{LABEL}: :model must be an ActiveRecord model class, true (infer from controller name), or nil"
567
+ end
568
+
569
+ model
570
+ end
571
+ end
572
+
573
+ def infer_permit_model
574
+ unless respond_to?(:controller_name)
575
+ raise ArgumentError, "#{LABEL}: model: true needs controller_name to infer from — pass the class explicitly (model: SomeModel)"
576
+ end
577
+
578
+ name = controller_name.classify
579
+ name.safe_constantize ||
580
+ raise(ArgumentError, "#{LABEL}: model: true inferred #{name} from '#{controller_name}' but no such class exists — " \
581
+ "pass the class explicitly (model: SomeModel)")
582
+ end
583
+
584
+ # The drift guard. Nested/array fields are implicitly virtual — only
585
+ # scalar fields map one-to-one onto columns.
586
+ def guard_contract_columns!(model_class, fields)
587
+ checked = fields.select { |f| f[:kind] == :scalar && !f[:virtual] }
588
+ return if checked.empty?
589
+
590
+ types = checked.to_h { |f| [f[:name], f[:type]] }
591
+ begin
592
+ ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] }, types: types)
593
+ rescue ArgumentError => e
594
+ raise ArgumentError, "#{e.message} If this parameter is not backed by a column, declare it with virtual: true."
595
+ end
596
+ end
597
+
598
+ def register_sensitive_params(fields)
599
+ fields.each do |field|
600
+ Permittable.filter_parameter_registry.add(field[:name]) if field[:sensitive]
601
+ register_sensitive_params(field[:fields]) if field[:fields]
602
+ end
603
+ end
604
+ end
605
+
606
+ # The contract's output: a HashWithIndifferentAccess of cast, validated,
607
+ # defaulted values for the given action (default: the current action).
608
+ # Absent optional fields are omitted. Raises InvalidParameters on
609
+ # violation; raises ArgumentError when no contract covers the action
610
+ # (that is a programmer error, not a client error). Memoized per action.
611
+ def permitted_params(action = nil)
612
+ action = (action || permittable_action_name).to_s
613
+ raise ArgumentError, "#{LABEL}: no action given and action_name is not set" if action.empty?
614
+
615
+ @permittable_validated ||= {}
616
+ return @permittable_validated[action] if @permittable_validated.key?(action)
617
+
618
+ rule = self.class.permit_rule_for(action)
619
+ raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
620
+
621
+ @permittable_validated[action] = validate_params_contract!(rule)
622
+ end
623
+
624
+ # before_action entry point (public so hosts can `skip_before_action
625
+ # :enforce_params_contract`). Only rules that opted in with
626
+ # `enforce: true` validate here.
627
+ def enforce_params_contract
628
+ action = permittable_action_name
629
+ return nil unless action
630
+
631
+ rule = self.class.permit_rule_for(action)
632
+ permitted_params(action) if rule && rule[:enforce]
633
+ nil
634
+ end
635
+
636
+ # rescue_from target — renders through the shared envelope (the host's
637
+ # render_error when present, the identical inline shape otherwise).
638
+ def render_invalid_parameters(error)
639
+ ErrorEnvelope.render(
640
+ self, message: error.message, status: error.status,
641
+ code: "invalid_parameters", details: error.details
642
+ )
643
+ end
644
+
645
+ private
646
+
647
+ def validate_params_contract!(rule)
648
+ violations = []
649
+ source = permittable_root_hash(rule, violations)
650
+ result = ActiveSupport::HashWithIndifferentAccess.new
651
+ if source
652
+ result = permittable_check_hash(rule[:fields], source, path: rule[:root] ? rule[:root].to_s : nil,
653
+ unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
654
+ end
655
+ # finalize only sees a hash every field vouched for — never garbage.
656
+ if violations.empty? && rule[:finalize]
657
+ result = permittable_run_finalize(rule[:finalize], result, violations)
658
+ end
659
+ return result if violations.empty?
660
+
661
+ raise_invalid_parameters!(violations, status: source ? :unprocessable_entity : :bad_request)
662
+ end
663
+
664
+ def raise_invalid_parameters!(violations, status:)
665
+ violations.each(&:freeze)
666
+ ActiveSupport::Notifications.instrument(
667
+ "invalid_parameters.permittable",
668
+ controller: permittable_controller_name, action: permittable_action_name, details: violations
669
+ )
670
+ summary = violations.map { |v| "#{v[:param]} (#{v[:code]})" }.join(", ")
671
+ raise InvalidParameters.new("Invalid parameters: #{summary}", details: violations, status: status)
672
+ end
673
+
674
+ def permittable_run_finalize(finalizer, result, violations)
675
+ runner = FinalizeRunner.new(violations)
676
+ finalized = catch(:permittable_finalize_halt) do
677
+ runner.instance_exec(result, &finalizer)
678
+ end
679
+ return result unless violations.empty?
680
+ unless finalized.is_a?(Hash)
681
+ raise ArgumentError,
682
+ "#{LABEL}: finalize must return the params Hash (got #{finalized.class}) — " \
683
+ "end the block with the hash, e.g. `p` or `p.except(:scaffolding)`"
684
+ end
685
+
686
+ finalized.is_a?(ActiveSupport::HashWithIndifferentAccess) ? finalized : ActiveSupport::HashWithIndifferentAccess.new(finalized)
687
+ end
688
+
689
+ def permittable_root_hash(rule, violations)
690
+ raw = permittable_plain_params
691
+ return raw unless rule[:root]
692
+
693
+ value = raw[rule[:root].to_s]
694
+ return value if value.is_a?(Hash)
695
+
696
+ violations << { param: rule[:root].to_s, code: "missing" }
697
+ nil
698
+ end
699
+
700
+ # One plain HashWithIndifferentAccess view of `params`, whatever the
701
+ # stack: ActionController::Parameters (to_unsafe_h — this concern does
702
+ # its own permitting, that is the point) or a plain hash in tests.
703
+ def permittable_plain_params
704
+ raw = params
705
+ raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
706
+ ActiveSupport::HashWithIndifferentAccess.new(raw)
707
+ end
708
+
709
+ def permittable_check_hash(fields, hash, path:, unknown:, top_level:, violations:)
710
+ result = ActiveSupport::HashWithIndifferentAccess.new
711
+ fields.each do |field|
712
+ key = field[:name].to_s
713
+ full = permittable_path(path, key)
714
+ value = hash[key]
715
+
716
+ if permittable_absent?(value, hash, key)
717
+ if field.key?(:default)
718
+ result[key] = field[:default]
719
+ elsif field[:required]
720
+ violations << { param: full, code: "missing" }
721
+ end
722
+ next
723
+ end
724
+
725
+ permittable_check_field(field, value, full, result, unknown: unknown, violations: violations)
726
+ end
727
+ permittable_check_unknown(fields, hash, path: path, unknown: unknown, top_level: top_level, violations: violations)
728
+ result
729
+ end
730
+
731
+ def permittable_check_field(field, value, full, result, unknown:, violations:)
732
+ key = field[:name].to_s
733
+ case field[:kind]
734
+ when :scalar
735
+ status, out = Coercion.check_scalar(field, value)
736
+ if status == :ok
737
+ out = field[:transform].call(out) if field[:transform]
738
+ result[key] = out
739
+ else
740
+ violations << { param: full, code: out }
741
+ end
742
+ when :nested
743
+ if value.is_a?(Hash)
744
+ result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
745
+ path: full, unknown: unknown, top_level: false, violations: violations)
746
+ else
747
+ violations << { param: full, code: "invalid_type" }
748
+ end
749
+ when :array
750
+ if value.is_a?(Array)
751
+ result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
752
+ else
753
+ violations << { param: full, code: "invalid_type" }
754
+ end
755
+ end
756
+ end
757
+
758
+ def permittable_check_array(field, value, path:, unknown:, violations:)
759
+ before = violations.length
760
+ if field[:length] && !Coercion.length_ok?(field[:length], value.length)
761
+ violations << { param: path, code: "length" }
762
+ end
763
+ out = value.each_with_index.map do |element, index|
764
+ permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
765
+ end
766
+ if field[:validate]
767
+ status, code = Coercion.check_custom(field[:validate], out)
768
+ violations << { param: path, code: code } unless status == :ok
769
+ end
770
+ # Transform only a fully-valid array — a partially-nil one (element
771
+ # violations) would hand user code garbage it never agreed to see.
772
+ out = field[:transform].call(out) if field[:transform] && violations.length == before
773
+ out
774
+ end
775
+
776
+ def permittable_check_element(field, element, path, unknown:, violations:)
777
+ if field[:fields]
778
+ unless element.is_a?(Hash)
779
+ violations << { param: path, code: "invalid_type" }
780
+ return nil
781
+ end
782
+ return permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(element),
783
+ path: path, unknown: unknown, top_level: false, violations: violations)
784
+ end
785
+
786
+ status, out = Coercion.cast(field[:of], element)
787
+ return out if status == :ok
788
+
789
+ violations << { param: path, code: out }
790
+ nil
791
+ end
792
+
793
+ # nil and "" are both ABSENT — see the module comment.
794
+ def permittable_absent?(value, hash, key)
795
+ !hash.key?(key) || value.nil? || (value.is_a?(String) && value.empty?)
796
+ end
797
+
798
+ def permittable_check_unknown(fields, hash, path:, unknown:, top_level:, violations:)
799
+ return if unknown == :ignore
800
+
801
+ declared = fields.map { |f| f[:name].to_s }
802
+ extra = hash.keys.map(&:to_s) - declared
803
+ extra -= ROUTING_KEYS if top_level
804
+ return if extra.empty?
805
+
806
+ if unknown == :error
807
+ extra.each { |key| violations << { param: permittable_path(path, key), code: "unknown" } }
808
+ elsif respond_to?(:logger) && logger
809
+ logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: " \
810
+ "#{extra.map { |key| permittable_path(path, key) }.join(', ')}")
811
+ end
812
+ end
813
+
814
+ def permittable_path(path, key)
815
+ path ? "#{path}.#{key}" : key
816
+ end
817
+
818
+ def permittable_action_name
819
+ respond_to?(:action_name) && action_name ? action_name.to_s : nil
820
+ end
821
+
822
+ def permittable_controller_name
823
+ return controller_path if respond_to?(:controller_path)
824
+
825
+ self.class.name
826
+ end
827
+ end
828
+
829
+ # Boot-time integration (filter_parameters registration), Rails apps only
830
+ require "permittable/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: permittable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ethan Nguyen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ description: 'Declarative per-action params contracts: strict typing/coercion, validation,
34
+ defaults, machine-readable 422s, output reshaping (transform/finalize), and a boot-time
35
+ schema-drift guard that fails the deploy when a permitted field''s column was dropped.
36
+ Strong parameters with types, validation, and drift detection.'
37
+ email:
38
+ - doctorit@gmail.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - CHANGELOG.md
44
+ - LICENSE.txt
45
+ - README.md
46
+ - lib/permittable.rb
47
+ - lib/permittable/column_guard.rb
48
+ - lib/permittable/error_envelope.rb
49
+ - lib/permittable/filter_parameter_registry.rb
50
+ - lib/permittable/railtie.rb
51
+ - lib/permittable/version.rb
52
+ homepage: https://github.com/VSN2015/permittable
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ license: MIT
57
+ homepage_uri: https://github.com/VSN2015/permittable
58
+ source_code_uri: https://github.com/VSN2015/permittable
59
+ changelog_uri: https://github.com/VSN2015/permittable/blob/master/CHANGELOG.md
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.2.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.4.10
76
+ signing_key:
77
+ specification_version: 4
78
+ summary: Typed, validated params contracts for Rails controllers + schema-drift guard
79
+ test_files: []