permittable 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84f1f4ca36c37a54ce5117ee6ec10bbd2e5dd5e9626e031c4dc35e8448ba1c35
4
- data.tar.gz: c9ad5b56d78d44fd15de269547f431fd3441ec7233bc191cfa4c3ce391e8f19b
3
+ metadata.gz: 577c3a6c767e85dd2b3fc0ad8049e1d0ec77d2b87bc1f6eedca351161f589ef9
4
+ data.tar.gz: bed2c92d56a70abf45f8f9cc4b779788886623b442054d63727893f891eb0c13
5
5
  SHA512:
6
- metadata.gz: 0a1b6d1805eca305ba87b6ab7b78b238db6230255dcf14fc69096ff37f352c2cb4cc14199f1a68dfa19863dee745583b79f73510de8597a99dbc55c2cd1c01dc
7
- data.tar.gz: 3630d2cba76050554c72cb763b2dd95f9442f2f12032b7b35932139f2ea0c887ab4f460178deff932d80d74d41b797ceac0f30cd9551984530c05d96a06c8b6a
6
+ metadata.gz: bd955e930e66036f2bffd9f006996ad0713e3582eda8386e51bc668d42432db7f941bef9724ba5c6589e85be4ce91827cc8536f49ca6c0d698562de0aee65520
7
+ data.tar.gz: a61f63950413801e405a6690e141a35616d974cc347bdae86e9a6f239a75e89225604b973acb4daf4b7daa25492e767175f674de6376df62eb0128e27f16fa62
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.2.0 (2026-08-18)
4
+ <!-- title: custom error messages -->
5
+
6
+ ### Added
7
+ - `message:` field option for customizing violation messages, on every field kind (scalar, array, nested). A String covers every violation code on the field; a Hash of code → String targets specific codes — including Symbol codes returned by `validate:` — while unmatched codes keep the default rendering. A resolved message is carried in the violation detail as `message:` and replaces the `(code)` part of the `InvalidParameters` summary, so it flows into the error envelope and the `invalid_parameters.permittable` instrumentation payload unchanged. An array's message also covers its elements' violations; a malformed `message:` raises at class load like every other contract mistake.
8
+ - `violate!(param, code, message: nil)` — finalize's violation verb accepts the same optional human-readable message.
9
+
10
+ Contracts that don't opt in are byte-for-byte unaffected: details keep the bare `{ param:, code: }` shape and the summary keeps its `param (code)` rendering.
11
+
3
12
  ## 0.1.2 (2026-08-16)
4
13
  <!-- title: gem metadata -->
5
14
 
data/README.md CHANGED
@@ -1,8 +1,16 @@
1
1
  # Permittable
2
2
 
3
- **Typed, validated params contracts for Rails controllers — plus a schema-drift guard.**
3
+ [![Gem Version](https://img.shields.io/gem/v/permittable.svg)](https://rubygems.org/gems/permittable)
4
+ [![CI](https://github.com/VSN2015/permittable/actions/workflows/ci.yml/badge.svg)](https://github.com/VSN2015/permittable/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
4
6
 
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.
7
+ **Strong parameters for Rails that also know types, bounds, and defaults.**
8
+
9
+ `params.permit` answers exactly one question: *which keys may pass?* Everything else — is `age` really a number, is `email` shaped like an email, what should `plan` be when the client omits it, and *why* was this request rejected — is left to you, usually as hand-written checks scattered through the action.
10
+
11
+ A Permittable contract answers those questions too. It **casts** each field to a declared type, **validates** it, applies **defaults**, optionally **reshapes** the output, and turns every failure into a machine-readable 422 that names the offending parameter.
12
+
13
+ And because a contract is *class-level data* rather than code inside the action, it can be inspected — and checked against your database when the controller loads, so a column dropped by a migration fails the deploy instead of the request.
6
14
 
7
15
  ```ruby
8
16
  class UsersController < ApplicationController
@@ -22,12 +30,12 @@ class UsersController < ApplicationController
22
30
  end
23
31
 
24
32
  def create
25
- user = User.create!(permitted_params) # cast, validated, defaulted
33
+ User.create!(permitted_params) # cast, validated, defaulted
26
34
  end
27
35
  end
28
36
  ```
29
37
 
30
- A violating request renders:
38
+ A violating request never reaches your action:
31
39
 
32
40
  ```json
33
41
  { "success": false,
@@ -36,56 +44,275 @@ A violating request renders:
36
44
  "details": [{ "param": "user.age", "code": "inclusion" }] } }
37
45
  ```
38
46
 
47
+ ---
48
+
49
+ ## Contents
50
+
51
+ - [Why](#why) · [Installation](#installation) · [How a request flows](#how-a-request-flows)
52
+ - [Declaring a contract](#declaring-a-contract) · [The field DSL](#the-field-dsl) · [Field options](#field-options)
53
+ - [Types and strict coercion](#types-and-strict-coercion) · [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
54
+ - [Violations and error responses](#violations-and-error-responses) · [Custom error messages](#custom-error-messages-message) · [Unknown parameters](#unknown-parameters)
55
+ - [Output reshaping](#output-reshaping-transform-and-finalize) · [The schema-drift guard](#the-schema-drift-guard)
56
+ - [Sensitive parameters](#sensitive-parameters-and-log-redaction) · [Instrumentation](#instrumentation)
57
+ - [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
58
+
59
+ ---
60
+
61
+ ## Why
62
+
63
+ | | `params.permit` | `params.expect` (Rails 8) | Permittable |
64
+ |---|---|---|---|
65
+ | Filters unknown keys | ✅ | ✅ | ✅ |
66
+ | Requires a root key | via `require` | ✅ | ✅ |
67
+ | Casts to a declared type | ❌ | ❌ | ✅ |
68
+ | Validates bounds, formats, sets | ❌ | ❌ | ✅ |
69
+ | Supplies defaults | ❌ | ❌ | ✅ |
70
+ | Machine-readable error details | ❌ | ❌ | ✅ |
71
+ | Reshapes output | ❌ | ❌ | ✅ |
72
+ | Checked against your schema at boot | ❌ | ❌ | ✅ |
73
+
74
+ The design rests on one idea: **a contract is data, not code.** It is declared once at the class level, frozen, inheritable, and introspectable. Everything else here follows from that — the drift guard can read it at boot, `finalize` can run on a bare object with no controller state, and the whole contract can be printed or tested without a request.
75
+
39
76
  ## Installation
40
77
 
41
78
  ```ruby
42
79
  gem "permittable"
43
80
  ```
44
81
 
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.
82
+ Then include it wherever you need it typically once in `ApplicationController`:
46
83
 
47
- ## The schema-drift guard
84
+ ```ruby
85
+ class ApplicationController < ActionController::Base
86
+ include Permittable
87
+ end
88
+ ```
89
+
90
+ The **only runtime dependency is `activesupport`**. `actionpack` (for `rescue_from`, `before_action`, and `ActionController::Parameters`) and `activerecord` (for the `model:` schema-drift guard) are optional — every touchpoint is guarded with `respond_to?`/`defined?`, so your app brings whatever it already has. The concern works on a plain Ruby object that responds to `params`, which is what makes it straightforward to unit-test.
48
91
 
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:
92
+ > **Naming note:** some legacy stacks (InheritedResources) define their own `permitted_params`. Don't include both on one controller.
93
+
94
+ ## How a request flows
50
95
 
51
96
  ```
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.
97
+ params
98
+
99
+ ├─ 1. unwrap root: params[:user] → 400 if missing or not a hash
100
+ ├─ 2. per field: normalize → cast → validate → transform
101
+ ├─ 3. unknown-key check at every nesting level
102
+ ├─ 4. finalize only if nothing violated
103
+
104
+ └─ permitted_params → HashWithIndifferentAccess (or raises InvalidParameters)
55
105
  ```
56
106
 
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
107
+ Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
60
108
 
61
- ### `permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &contract)`
109
+ ## Declaring a contract
62
110
 
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).
111
+ ```ruby
112
+ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &contract)
113
+ ```
64
114
 
65
115
  | Option | Default | Meaning |
66
116
  |---|---|---|
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` |
117
+ | `*actions` | — | Actions the contract covers. **No actions = catch-all** for the controller |
118
+ | `root:` | `false` | Key to unwrap first (the `require(:user)` equivalent). Missing or non-hash root → **400** |
119
+ | `model:` | `nil` | Model class, or `true` to infer from `controller_name`, enabling the [drift guard](#the-schema-drift-guard) |
120
+ | `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — how to treat undeclared keys |
121
+ | `enforce:` | `false` | `false` validates lazily on first use; `true` validates in a `before_action` |
122
+
123
+ `permit_params` is **repeatable**, and **the last matching rule wins**. Contracts behave like configuration: a base controller declares a catch-all, and a subclass overrides it for specific actions.
124
+
125
+ ```ruby
126
+ class ApiController < ApplicationController
127
+ permit_params(unknown: :error) { optional :page, :integer, in: 1..1000 } # catch-all
128
+ end
129
+
130
+ class ReportsController < ApiController
131
+ permit_params :export, root: :report do # wins for #export
132
+ required :format, :string, in: %w[csv pdf]
133
+ end
134
+ end
135
+ ```
136
+
137
+ Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
138
+
139
+ ## The field DSL
140
+
141
+ ### Scalars
142
+
143
+ ```ruby
144
+ required :name, :string # type defaults to :string
145
+ optional :age, :integer
146
+ ```
147
+
148
+ ### Nested hashes
149
+
150
+ Pass a block instead of a type. Violation paths are dotted (`user.address.zip`).
151
+
152
+ ```ruby
153
+ optional :address do
154
+ required :city, :string
155
+ optional :zip, :string, format: /\A\d{5}\z/
156
+ end
157
+ ```
158
+
159
+ ### Arrays
160
+
161
+ `of:` declares an array of scalars; a block declares an array of hashes. Arrays are **optional unless `required: true`**, `length:` constrains the element **count**, and element failures carry their index (`items[1]`).
162
+
163
+ ```ruby
164
+ array :tag_names, of: :string, length: 0..10
165
+ array :line_items, required: true do
166
+ required :sku, :string
167
+ required :quantity, :integer, in: 1..99
168
+ end
169
+ ```
170
+
171
+ ## Field options
172
+
173
+ Which options are legal depends on the field kind — anything else raises at class load.
174
+
175
+ | Option | Scalar | Array | Nested | Meaning |
176
+ |---|:---:|:---:|:---:|---|
177
+ | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
178
+ | `format:` | ✅¹ | — | — | Regexp the value must match |
179
+ | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays |
180
+ | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **before** the cast |
181
+ | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load |
182
+ | `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
183
+ | `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
184
+ | `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
185
+ | `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
186
+ | `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
187
+ | `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
188
+ | `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
189
+
190
+ ¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
191
+
192
+ `validate:` is the escape hatch for anything the built-ins don't cover:
193
+
194
+ ```ruby
195
+ optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
196
+ ```
197
+
198
+ ## Types and strict coercion
199
+
200
+ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`. Rails' casts are lenient by design — `"abc".to_i` is `0`, `Boolean.cast("abc")` is `true` — and silently corrupting untrusted input is precisely what a contract must not do. A value the type cannot faithfully represent is a **violation, not a guess**.
201
+
202
+ | Type | Accepts | Rejects (`invalid_type`) |
203
+ |---|---|---|
204
+ | `:string` | `String`; `Numeric`/`true`/`false` are stringified | Arrays, hashes |
205
+ | `:integer` | `Integer`; whole `Float`s (`4.0`); base-10 numeric strings | `"4.5"`, `"abc"`, `4.5` |
206
+ | `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
207
+ | `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
208
+ | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
209
+ | `:date` | `Date`; any `Date.parse`-able string | Unparseable strings |
210
+ | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`, parseable strings | Unparseable strings |
211
+
212
+ Two behaviours worth committing to memory:
213
+
214
+ - **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
215
+ - **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
216
+
217
+ ## Absence, defaults, and partial updates
218
+
219
+ `nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present.
220
+
221
+ That single rule produces the behaviour you want from a `PATCH`:
222
+
223
+ - An **absent optional field is omitted** from the result, so partial updates never nil-out columns.
224
+ - An **absent required field violates** with `missing`.
225
+ - An absent field **with a `default:` gets the default** — so a defaulted field can never report `missing`. (Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality.)
226
+
227
+ Because absence and `nil` are the same thing here, **clearing a column to NULL is outside a contract's vocabulary**. Do that explicitly in the action.
228
+
229
+ Defaults are checked against the field's own contract when the class loads, so `default: "gold"` on a field declared `in: %w[free pro]` fails at boot rather than on every request.
72
230
 
73
- ### Field DSL
231
+ ## Violations and error responses
74
232
 
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.
233
+ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope.
78
234
 
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:`.
235
+ | Code | Raised when |
236
+ |---|---|
237
+ | `missing` | A required field is absent, or the `root:` key is missing (this one is a **400**) |
238
+ | `invalid_type` | The value cannot be faithfully cast to the declared type |
239
+ | `inclusion` | The value is outside `in:` |
240
+ | `format` | The value doesn't match `format:` |
241
+ | `length` | A string's length, or an array's element count, is outside `length:` |
242
+ | `unknown` | An undeclared key was sent while `unknown: :error` |
243
+ | `invalid` | A `validate:` callable returned a falsy value |
244
+ | *your symbol* | A `validate:` callable returned a `Symbol`, or `violate!` was called in `finalize` |
80
245
 
81
- Every bad declaration raises a teaching `ArgumentError` at class load.
246
+ Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
82
247
 
83
- ## Output reshaping (`transform:` / `finalize`)
248
+ **Status codes:** a missing root key renders **400** (the request is malformed — the envelope you asked for isn't there); field-level violations render **422** (well-formed, semantically wrong).
84
249
 
85
- The safe replacement for params-mutating before_actionsboth layers operate on the validated **copy**; the request's `params` is never touched.
250
+ **Custom rendering:** if your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` the `errors:` key is passed only when details exist, so hosts documenting a three-keyword contract keep working. Otherwise the inline JSON shape shown at the top of this README is rendered. Either way, `render_invalid_parameters` is a normal method you can override.
86
251
 
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.
252
+ ## Custom error messages (`message:`)
253
+
254
+ Violations stay machine-first — the `code` is the contract — but any field can attach human-readable copy with `message:`. A **String** covers every code on the field; a **Hash of code → String** targets specific codes, and codes without an entry keep the default rendering:
255
+
256
+ ```ruby
257
+ permit_params :create, root: :user do
258
+ required :email, :string, format: URI::MailTo::EMAIL_REGEXP,
259
+ message: { missing: "is required", format: "must be a valid email address" }
260
+ optional :age, :integer, in: 18..120, message: "must be between 18 and 120"
261
+ array :tags, of: :string, length: 0..10, message: "must be at most ten tags"
262
+ end
263
+ ```
264
+
265
+ A resolved message rides into the violation detail and replaces the `(code)` part of the exception's summary line, so both the envelope's `message` and its `details` read naturally:
266
+
267
+ ```json
268
+ { "success": false,
269
+ "error": { "message": "Invalid parameters: user.email must be a valid email address",
270
+ "code": "invalid_parameters",
271
+ "details": [{ "param": "user.email", "code": "format",
272
+ "message": "must be a valid email address" }] } }
273
+ ```
274
+
275
+ The rules:
276
+
277
+ - Messages are written to read after the param name: `"is required"`, not `"Email is required"`.
278
+ - A Hash key matches the violation code, **including Symbol codes returned by `validate:`** — `validate: ->(v) { v.even? || :must_be_even }, message: { must_be_even: "must be an even number" }`.
279
+ - An array's message covers the array's own violations (`length`, `invalid_type`, `missing`) **and** its elements' (`tags[3]`); sub-fields of a nested block resolve their own `message:` declarations.
280
+ - `violate!` in `finalize` takes the same idea as a keyword: `violate!("user.ends_at", :before_start, message: "must be after starts_at")`.
281
+ - A `message:` that is neither a String nor a code → String Hash raises at class load, like every other contract mistake.
282
+
283
+ Fields without a `message:` are untouched — their details keep the bare `{ param:, code: }` shape. For full control over the response body itself (localization, RFC 9457, a different envelope), override `render_invalid_parameters` or define `render_error` as described above; `error.details` gives you the structured violations to build from.
284
+
285
+ ## Unknown parameters
286
+
287
+ `unknown:` decides what happens to keys you never declared, **at every nesting level**.
288
+
289
+ | Mode | Behaviour |
290
+ |---|---|
291
+ | `:ignore` (default) | Silently dropped, exactly like strong parameters |
292
+ | `:log` | Dropped, with a `logger.warn` naming the full paths |
293
+ | `:error` | Each undeclared key becomes an `unknown` violation |
294
+
295
+ Rails merges `controller`, `action`, and `format` into `params`; these are exempt at the top level so `unknown: :error` doesn't flag the router's own bookkeeping. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there.
296
+
297
+ ## Output reshaping (`transform:` and `finalize`)
298
+
299
+ This is the safe replacement for params-mutating `before_action`s. **Both layers operate on the validated copy — the request's `params` is never touched.**
300
+
301
+ ### `transform:` — per field
302
+
303
+ A callable applied **after** cast and validation, reshaping one field's output:
304
+
305
+ ```ruby
306
+ required :tags, :string, transform: ->(v) { v.split(",") }
307
+ ```
308
+
309
+ It runs only on request-supplied values. Absent fields stay absent, `default:` values are authored in their final shape, and a **partially-invalid array is never transformed** — user code is never handed garbage it didn't agree to see.
310
+
311
+ ### `finalize` — per contract
312
+
313
+ Declared once, at the top level only. It runs after every field has validated cleanly, receives the result hash, and must return the final `Hash`. Use it to combine parallel fields, build value objects, or drop scaffolding keys.
314
+
315
+ It executes on a **bare runner, not the controller**, so contracts stay pure data plus pure functions and can never grow a dependency on request state. Its one extra verb is `violate!(param, code, message: nil)`, which records a violation (the optional [`message:`](#custom-error-messages-message) rides into the detail) and **halts the block immediately** — so the code after a `violate!` may assume the invariant it just checked. That makes `finalize` the natural home for cross-field validation (`ends_at` after `starts_at`, matching array lengths).
89
316
 
90
317
  ```ruby
91
318
  permit_params :create, root: :lease_addendum_form do
@@ -93,36 +320,130 @@ permit_params :create, root: :lease_addendum_form do
93
320
  required :signer_names, :string, transform: ->(v) { v.split(",") }
94
321
 
95
322
  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) }
323
+ unless p[:signer_names].length == p[:resident_signatures].length
324
+ violate!("lease_addendum_form.signer_names", :length_mismatch)
325
+ end
326
+
327
+ p[:signatures] = p[:resident_signatures].zip(p[:signer_names]).map do |image, name|
328
+ Signature.new(image: image, full_name: name)
329
+ end
98
330
  p.except(:resident_signatures, :signer_names)
99
331
  end
100
332
  end
101
333
  ```
102
334
 
103
- ## Methods
335
+ Forgetting to return the hash raises an `ArgumentError` telling you exactly that.
104
336
 
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`.
337
+ ## The schema-drift guard
110
338
 
111
- ## Semantics worth knowing
339
+ This is why `model:` exists. Pass a model class (or `true` to infer it from `controller_name`) and **every non-virtual scalar field is checked against the model's columns when the macro runs** — that is, at controller class load.
112
340
 
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.
341
+ Production eager-loads controllers, so a column dropped by a migration **fails the deploy, not the request**:
342
+
343
+ ```
344
+ Permittable: 'nickname' does not exist in the database (table: users).
345
+ Add it with: bin/rails generate migration AddNicknameToUsers nickname:string
346
+ If this parameter is not backed by a column, declare it with virtual: true.
347
+ ```
348
+
349
+ The error carries a ready-to-paste migration command, typed from your own field declaration.
350
+
351
+ - **Fields not backed by a column** — `password_confirmation`, terms checkboxes, search filters — opt out with `virtual: true`.
352
+ - **Nested and array fields are implicitly virtual**, since only scalars map one-to-one onto columns.
353
+ - **The check skips when the schema is unreachable** (`db:create`, a fresh `db:migrate`, `assets:precompile`, CI bootstrap), so controller classes stay loadable. Skipping is self-healing: once the migration runs and classes reload, the check happens for real. A missing column with a *reachable* schema still raises — the rescue is scoped to `ActiveRecord::ActiveRecordError` precisely so real bugs keep surfacing.
354
+
355
+ In CI, one spec calling `Rails.application.eager_load!` exercises every contract in the whole app.
356
+
357
+ ## Sensitive parameters and log redaction
358
+
359
+ Mark a field `sensitive: true` and its name is registered with `Permittable.filter_parameter_registry`; `Permittable::Railtie` appends a filter proc to `config.filter_parameters` at boot.
360
+
361
+ ```ruby
362
+ optional :ssn, :string, sensitive: true
363
+ ```
364
+
365
+ The indirection is deliberate. Appending plain symbols to `config.filter_parameters` at class-load time misses every consumer that snapshots the list at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers, precompiled filters. A **single proc appended once at boot, consulting a live registry at filter time**, means fields registered when a controller loads later (lazy loading in development) are still redacted. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
366
+
367
+ Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own.
368
+
369
+ ## Instrumentation
370
+
371
+ Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
372
+
373
+ ```ruby
374
+ ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
375
+ payload[:controller] # "users"
376
+ payload[:action] # "create"
377
+ payload[:details] # [{ param: "user.age", code: "inclusion" }]
378
+ end
379
+ ```
380
+
381
+ ## API reference
382
+
383
+ ### Instance methods
384
+
385
+ | Method | Purpose |
386
+ |---|---|
387
+ | `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation, or `ArgumentError` when no contract covers the action |
388
+ | `enforce_params_contract` | The `before_action` entry point. Only validates rules declared `enforce: true`. Public, so hosts can `skip_before_action` it |
389
+ | `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
390
+
391
+ ### Class methods
392
+
393
+ | Method | Purpose |
394
+ |---|---|
395
+ | `permit_params(*actions, **opts, &contract)` | Declare a contract |
396
+ | `permittable_contracts` | The frozen array of every declared rule — introspectable, testable |
397
+ | `permit_rule_for(action)` | The last rule matching `action`, or `nil` |
398
+
399
+ ### Module
400
+
401
+ | | |
402
+ |---|---|
403
+ | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
404
+ | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
405
+ | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
406
+
407
+ ## Errors caught at class load
408
+
409
+ A bad contract is a programmer error, so it fails when the class loads — never at request time. Every message names the field and explains the fix.
410
+
411
+ - A field declared twice in one contract
412
+ - An unknown option for the field's kind, listing what *is* allowed
413
+ - An unknown type, listing the supported ones
414
+ - An unknown `normalize:` preset, listing the presets
415
+ - `format:`, `length:`, or `normalize:` on a non-`:string` field
416
+ - `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
417
+ - `validate:` or `transform:` that isn't callable
418
+ - A `default:` that violates its own field's contract, or an array `default:` whose elements violate `of:`
419
+ - `required: true` combined with `default:`
420
+ - A field given both a type and a nested block; an array given both `of:` and a block
421
+ - An empty contract, or a nested block declaring no sub-fields
422
+ - `finalize` declared twice, without a block, or inside a nested block
423
+ - `permit_params` without a block, or an invalid `unknown:` mode
424
+ - A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
425
+
426
+ ## Compatibility
427
+
428
+ | | |
429
+ |---|---|
430
+ | Ruby | >= 3.2 |
431
+ | Rails / ActiveSupport | >= 5.0, < 9 |
432
+ | Required dependency | `activesupport` only |
433
+ | Optional | `actionpack` (rendering, `before_action`), `activerecord` (drift guard) |
434
+
435
+ Using [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `ConcernsOnRails::Controllers::Permittable` is an alias for this module, and `sensitive:` registrations pool into that gem's shared filter registry.
118
436
 
119
437
  ## Development
120
438
 
121
439
  ```sh
122
440
  bundle install
123
- bundle exec rspec
441
+ bundle exec rspec # 76 examples
442
+ bundle exec rubocop
124
443
  ```
125
444
 
445
+ Releases are automated: bump `lib/permittable/version.rb`, add a `CHANGELOG.md` section, then push a `vX.Y.Z` tag. CI publishes to RubyGems via trusted publishing (OIDC — no API keys stored) and creates the GitHub release.
446
+
126
447
  ## License
127
448
 
128
- MIT.
449
+ [MIT](LICENSE.txt).
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.1.2".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
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].freeze
317
+ NESTED_OPTS = %i[virtual sensitive message].freeze
318
+ ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message].freeze
310
319
 
311
320
  attr_reader :finalizer
312
321
 
@@ -360,6 +369,7 @@ module Permittable
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_default!(field) if field.key?(:default)
372
+ validate_message!(field)
363
373
  @fields << field
364
374
  end
365
375
 
@@ -371,15 +381,16 @@ module Permittable
371
381
  raise ArgumentError, "#{LABEL}: :#{name} takes a type OR a nested block, not both" if type
372
382
 
373
383
  assert_opts!(name, opts, NESTED_OPTS)
374
- @fields << { name: name, kind: :nested, required: required,
375
- fields: nested_fields!(name, &block), **opts }
384
+ field = { name: name, kind: :nested, required: required,
385
+ fields: nested_fields!(name, &block), **opts }
386
+ validate_message!(field)
376
387
  else
377
388
  assert_opts!(name, opts, SCALAR_OPTS)
378
389
  field = { name: name, kind: :scalar, required: required,
379
390
  type: scalar_type!(name, type || :string), **opts }
380
391
  validate_scalar_opts!(field)
381
- @fields << field
382
392
  end
393
+ @fields << field
383
394
  end
384
395
 
385
396
  def field_name!(name)
@@ -432,6 +443,7 @@ module Permittable
432
443
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
433
444
  resolve_normalizer!(field)
434
445
  validate_default!(field)
446
+ validate_message!(field)
435
447
  end
436
448
 
437
449
  # format / length / normalize reason about characters; on any other
@@ -492,6 +504,26 @@ module Permittable
492
504
  raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
493
505
  end
494
506
  end
507
+
508
+ # `message:` customizes what the client reads for a violation on this
509
+ # field: one String covering every code, or a Hash of code => String
510
+ # (codes without an entry keep the default rendering). Keys are
511
+ # normalized to Symbols here so request-time resolution is a plain
512
+ # lookup.
513
+ def validate_message!(field)
514
+ spec = field[:message]
515
+ return if spec.nil?
516
+ return if spec.is_a?(String)
517
+
518
+ valid_hash = spec.is_a?(Hash) && !spec.empty? &&
519
+ spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
520
+ unless valid_hash
521
+ raise ArgumentError, "#{LABEL}: :message for field :#{field[:name]} must be a String " \
522
+ "or a Hash of violation code => String (e.g. { missing: \"is required\" })"
523
+ end
524
+
525
+ field[:message] = spec.transform_keys(&:to_sym).freeze
526
+ end
495
527
  end
496
528
 
497
529
  # The `self` a finalize block runs on. Deliberately bare — no controller
@@ -504,9 +536,13 @@ module Permittable
504
536
 
505
537
  # Records ONE violation and halts the finalize block immediately (the
506
538
  # 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 }
539
+ # invariant). The contract then fails as a normal 422. An optional
540
+ # message: rides along into the violation detail, same as a field's
541
+ # `message:` option.
542
+ def violate!(param, code, message: nil)
543
+ entry = { param: param.to_s, code: code.to_s }
544
+ entry[:message] = message.to_s if message
545
+ @violations << entry
510
546
  throw :permittable_finalize_halt
511
547
  end
512
548
  end
@@ -663,10 +699,27 @@ module Permittable
663
699
  "invalid_parameters.permittable",
664
700
  controller: permittable_controller_name, action: permittable_action_name, details: violations
665
701
  )
666
- summary = violations.map { |v| "#{v[:param]} (#{v[:code]})" }.join(", ")
702
+ summary = violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
667
703
  raise InvalidParameters.new("Invalid parameters: #{summary}", details: violations, status: status)
668
704
  end
669
705
 
706
+ # One violation detail entry. A field's `message:` (String, or Hash keyed
707
+ # by code) attaches a human-readable message; entries without one keep
708
+ # the bare { param:, code: } shape, so existing consumers see no change.
709
+ def permittable_violation(field, param, code)
710
+ entry = { param: param, code: code.to_s }
711
+ message = permittable_message_for(field, code)
712
+ entry[:message] = message if message
713
+ entry
714
+ end
715
+
716
+ def permittable_message_for(field, code)
717
+ spec = field[:message]
718
+ return spec if spec.nil? || spec.is_a?(String)
719
+
720
+ spec[code.to_sym]
721
+ end
722
+
670
723
  def permittable_run_finalize(finalizer, result, violations)
671
724
  runner = FinalizeRunner.new(violations)
672
725
  finalized = catch(:permittable_finalize_halt) do
@@ -713,7 +766,7 @@ module Permittable
713
766
  if field.key?(:default)
714
767
  result[key] = field[:default]
715
768
  elsif field[:required]
716
- violations << { param: full, code: "missing" }
769
+ violations << permittable_violation(field, full, "missing")
717
770
  end
718
771
  next
719
772
  end
@@ -733,33 +786,33 @@ module Permittable
733
786
  out = field[:transform].call(out) if field[:transform]
734
787
  result[key] = out
735
788
  else
736
- violations << { param: full, code: out }
789
+ violations << permittable_violation(field, full, out)
737
790
  end
738
791
  when :nested
739
792
  if value.is_a?(Hash)
740
793
  result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
741
794
  path: full, unknown: unknown, top_level: false, violations: violations)
742
795
  else
743
- violations << { param: full, code: "invalid_type" }
796
+ violations << permittable_violation(field, full, "invalid_type")
744
797
  end
745
798
  when :array
746
799
  if value.is_a?(Array)
747
800
  result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
748
801
  else
749
- violations << { param: full, code: "invalid_type" }
802
+ violations << permittable_violation(field, full, "invalid_type")
750
803
  end
751
804
  end
752
805
  end
753
806
 
754
807
  def permittable_check_array(field, value, path:, unknown:, violations:)
755
808
  before = violations.length
756
- violations << { param: path, code: "length" } if field[:length] && !Coercion.length_ok?(field[:length], value.length)
809
+ violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
757
810
  out = value.each_with_index.map do |element, index|
758
811
  permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
759
812
  end
760
813
  if field[:validate]
761
814
  status, code = Coercion.check_custom(field[:validate], out)
762
- violations << { param: path, code: code } unless status == :ok
815
+ violations << permittable_violation(field, path, code) unless status == :ok
763
816
  end
764
817
  # Transform only a fully-valid array — a partially-nil one (element
765
818
  # violations) would hand user code garbage it never agreed to see.
@@ -770,7 +823,7 @@ module Permittable
770
823
  def permittable_check_element(field, element, path, unknown:, violations:)
771
824
  if field[:fields]
772
825
  unless element.is_a?(Hash)
773
- violations << { param: path, code: "invalid_type" }
826
+ violations << permittable_violation(field, path, "invalid_type")
774
827
  return nil
775
828
  end
776
829
  return permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(element),
@@ -780,7 +833,7 @@ module Permittable
780
833
  status, out = Coercion.cast(field[:of], element)
781
834
  return out if status == :ok
782
835
 
783
- violations << { param: path, code: out }
836
+ violations << permittable_violation(field, path, out)
784
837
  nil
785
838
  end
786
839
 
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.2.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-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport