permittable 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84f1f4ca36c37a54ce5117ee6ec10bbd2e5dd5e9626e031c4dc35e8448ba1c35
4
- data.tar.gz: c9ad5b56d78d44fd15de269547f431fd3441ec7233bc191cfa4c3ce391e8f19b
3
+ metadata.gz: aa679480fdac311694469096c7537926586e987ce2e7a724099611ec91dd3d2b
4
+ data.tar.gz: fa1785c674f0cbf388ce9f74b87f22344adef95cd5a5bfa6c81490fb8958a124
5
5
  SHA512:
6
- metadata.gz: 0a1b6d1805eca305ba87b6ab7b78b238db6230255dcf14fc69096ff37f352c2cb4cc14199f1a68dfa19863dee745583b79f73510de8597a99dbc55c2cd1c01dc
7
- data.tar.gz: 3630d2cba76050554c72cb763b2dd95f9442f2f12032b7b35932139f2ea0c887ab4f460178deff932d80d74d41b797ceac0f30cd9551984530c05d96a06c8b6a
6
+ metadata.gz: 66e7a85dad6e8029dbc8827b1a55ac14a297e3dc006315ffc66efc0e2f7dd6179ee6247e33f5de93a06d7fe08c3ff1205e30bf6dde6b16d93bfd381bdd3da813
7
+ data.tar.gz: fc0e4a920d62a687c960b808301841327bde86c85a2cf90c36c65e0ac61f133844e0733cd6ad3075298a8680d5a1360e9319495c4e60a9382449047bf6d76c0e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.3.0 (2026-08-24)
4
+ <!-- title: OpenAPI export -->
5
+
6
+ Contracts gain a third reader. The registry that already drives the validator and the schema-drift guard now also generates **OpenAPI 3.1** — because the schema is emitted from the same frozen data the server enforces, the docs cannot drift from the validation. Fully additive; no behaviour of existing contracts changes.
7
+
8
+ ### Added
9
+ - **`Permittable::JsonSchema`** — converts rules and fields into JSON Schema (draft 2020-12): types map onto their canonical JSON encodings (`:decimal` as `["string", "number"]` + `format: decimal`), `in:` → `enum`/`minimum`/`maximum`, `length:` → `minLength`/`maxLength` or `minItems`/`maxItems`, `format:` → `pattern` with `\A`/`\z` translated to `^`/`$`, `default:` → `default`, `unknown: :error` → `additionalProperties: false` at every level, `root:` → a required wrapper object, `sensitive:` → `writeOnly: true`. Required strings get `minLength: 1` (`""` is absent). What has no ECMA/JSON-Schema equivalent stays visible instead of guessed: Ruby-only or flagged regexps export as `x-permittable-pattern`, `validate:`/`transform:` as `x-permittable-custom-validation`/`x-permittable-transformed`, non-numeric Ranges as `x-permittable-range`. Emission is deterministic, so generated documents are committable and diff-stable.
10
+ - **`Permittable::OpenAPI`** — assembles full OpenAPI 3.1 documents (`.document`) and fragments (`.request_body_for`, `.operations_for`, `.components`) from any set of controllers, plain Ruby, no Rails required. Operations resolve through `permit_rule_for`, so last-matching-rule-wins holds in the docs exactly as at request time; every operation references shared components typing the 422 (and, for rooted contracts, 400) error envelope. Catch-all rules expand through `action_methods` (the concern's own public methods excluded) or surface as `"*"` + `x-permittable-catch-all`; unrouted operations land in `x-permittable-controllers` rather than being dropped.
11
+ - **`bin/rails permittable:openapi[output]`** — rake task (loaded by the Railtie) that eager-loads the app, collects every controller with contracts, maps actions onto `paths` via the route set (`:id` → `{id}`), and prints or writes the document. `OPENAPI_TITLE`/`OPENAPI_VERSION` override the `info` block.
12
+ - **`desc:` and `example:` field options, `desc:` on `permit_params`** — documentation passthrough carried on the frozen contract data and ignored by the runtime. An `example:` is validated against its own field's contract at class load, exactly like `default:`, so published examples can't lie either.
13
+
14
+ ## 0.2.0 (2026-08-18)
15
+ <!-- title: custom error messages -->
16
+
17
+ ### Added
18
+ - `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.
19
+ - `violate!(param, code, message: nil)` — finalize's violation verb accepts the same optional human-readable message.
20
+
21
+ 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.
22
+
3
23
  ## 0.1.2 (2026-08-16)
4
24
  <!-- title: gem metadata -->
5
25
 
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,280 @@ 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
+ - [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
58
+ - [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
59
+
60
+ ---
61
+
62
+ ## Why
63
+
64
+ | | `params.permit` | `params.expect` (Rails 8) | Permittable |
65
+ |---|---|---|---|
66
+ | Filters unknown keys | ✅ | ✅ | ✅ |
67
+ | Requires a root key | via `require` | ✅ | ✅ |
68
+ | Casts to a declared type | ❌ | ❌ | ✅ |
69
+ | Validates bounds, formats, sets | ❌ | ❌ | ✅ |
70
+ | Supplies defaults | ❌ | ❌ | ✅ |
71
+ | Machine-readable error details | ❌ | ❌ | ✅ |
72
+ | Reshapes output | ❌ | ❌ | ✅ |
73
+ | Checked against your schema at boot | ❌ | ❌ | ✅ |
74
+ | Exports OpenAPI / JSON Schema | ❌ | ❌ | ✅ |
75
+
76
+ 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.
77
+
39
78
  ## Installation
40
79
 
41
80
  ```ruby
42
81
  gem "permittable"
43
82
  ```
44
83
 
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.
84
+ Then include it wherever you need it typically once in `ApplicationController`:
46
85
 
47
- ## The schema-drift guard
86
+ ```ruby
87
+ class ApplicationController < ActionController::Base
88
+ include Permittable
89
+ end
90
+ ```
91
+
92
+ 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.
93
+
94
+ > **Naming note:** some legacy stacks (InheritedResources) define their own `permitted_params`. Don't include both on one controller.
48
95
 
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:
96
+ ## How a request flows
50
97
 
51
98
  ```
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.
99
+ params
100
+
101
+ ├─ 1. unwrap root: params[:user] → 400 if missing or not a hash
102
+ ├─ 2. per field: normalize → cast → validate → transform
103
+ ├─ 3. unknown-key check at every nesting level
104
+ ├─ 4. finalize only if nothing violated
105
+
106
+ └─ permitted_params → HashWithIndifferentAccess (or raises InvalidParameters)
55
107
  ```
56
108
 
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.
109
+ 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**.
58
110
 
59
- ## Configuration
111
+ ## Declaring a contract
60
112
 
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).
113
+ ```ruby
114
+ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &contract)
115
+ ```
64
116
 
65
117
  | Option | Default | Meaning |
66
118
  |---|---|---|
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` |
119
+ | `*actions` | — | Actions the contract covers. **No actions = catch-all** for the controller |
120
+ | `root:` | `false` | Key to unwrap first (the `require(:user)` equivalent). Missing or non-hash root → **400** |
121
+ | `model:` | `nil` | Model class, or `true` to infer from `controller_name`, enabling the [drift guard](#the-schema-drift-guard) |
122
+ | `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — how to treat undeclared keys |
123
+ | `enforce:` | `false` | `false` validates lazily on first use; `true` validates in a `before_action` |
124
+ | `desc:` | `nil` | Documentation only — becomes the operation description in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
125
+
126
+ `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.
127
+
128
+ ```ruby
129
+ class ApiController < ApplicationController
130
+ permit_params(unknown: :error) { optional :page, :integer, in: 1..1000 } # catch-all
131
+ end
132
+
133
+ class ReportsController < ApiController
134
+ permit_params :export, root: :report do # wins for #export
135
+ required :format, :string, in: %w[csv pdf]
136
+ end
137
+ end
138
+ ```
139
+
140
+ Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
141
+
142
+ ## The field DSL
143
+
144
+ ### Scalars
145
+
146
+ ```ruby
147
+ required :name, :string # type defaults to :string
148
+ optional :age, :integer
149
+ ```
150
+
151
+ ### Nested hashes
152
+
153
+ Pass a block instead of a type. Violation paths are dotted (`user.address.zip`).
154
+
155
+ ```ruby
156
+ optional :address do
157
+ required :city, :string
158
+ optional :zip, :string, format: /\A\d{5}\z/
159
+ end
160
+ ```
161
+
162
+ ### Arrays
163
+
164
+ `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]`).
165
+
166
+ ```ruby
167
+ array :tag_names, of: :string, length: 0..10
168
+ array :line_items, required: true do
169
+ required :sku, :string
170
+ required :quantity, :integer, in: 1..99
171
+ end
172
+ ```
173
+
174
+ ## Field options
175
+
176
+ Which options are legal depends on the field kind — anything else raises at class load.
177
+
178
+ | Option | Scalar | Array | Nested | Meaning |
179
+ |---|:---:|:---:|:---:|---|
180
+ | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
181
+ | `format:` | ✅¹ | — | — | Regexp the value must match |
182
+ | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays |
183
+ | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **before** the cast |
184
+ | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load |
185
+ | `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
186
+ | `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
187
+ | `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
188
+ | `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
189
+ | `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
190
+ | `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
191
+ | `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
192
+ | `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
193
+ | `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
194
+
195
+ ¹ `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.
196
+
197
+ `validate:` is the escape hatch for anything the built-ins don't cover:
198
+
199
+ ```ruby
200
+ optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
201
+ ```
202
+
203
+ ## Types and strict coercion
204
+
205
+ 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**.
206
+
207
+ | Type | Accepts | Rejects (`invalid_type`) |
208
+ |---|---|---|
209
+ | `:string` | `String`; `Numeric`/`true`/`false` are stringified | Arrays, hashes |
210
+ | `:integer` | `Integer`; whole `Float`s (`4.0`); base-10 numeric strings | `"4.5"`, `"abc"`, `4.5` |
211
+ | `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
212
+ | `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
213
+ | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
214
+ | `:date` | `Date`; any `Date.parse`-able string | Unparseable strings |
215
+ | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`, parseable strings | Unparseable strings |
216
+
217
+ Two behaviours worth committing to memory:
218
+
219
+ - **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.
220
+ - **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.
221
+
222
+ ## Absence, defaults, and partial updates
223
+
224
+ `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.
225
+
226
+ That single rule produces the behaviour you want from a `PATCH`:
227
+
228
+ - An **absent optional field is omitted** from the result, so partial updates never nil-out columns.
229
+ - An **absent required field violates** with `missing`.
230
+ - 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.)
231
+
232
+ 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.
72
233
 
73
- ### Field DSL
234
+ 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.
74
235
 
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.
236
+ ## Violations and error responses
78
237
 
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:`.
238
+ 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.
80
239
 
81
- Every bad declaration raises a teaching `ArgumentError` at class load.
240
+ | Code | Raised when |
241
+ |---|---|
242
+ | `missing` | A required field is absent, or the `root:` key is missing (this one is a **400**) |
243
+ | `invalid_type` | The value cannot be faithfully cast to the declared type |
244
+ | `inclusion` | The value is outside `in:` |
245
+ | `format` | The value doesn't match `format:` |
246
+ | `length` | A string's length, or an array's element count, is outside `length:` |
247
+ | `unknown` | An undeclared key was sent while `unknown: :error` |
248
+ | `invalid` | A `validate:` callable returned a falsy value |
249
+ | *your symbol* | A `validate:` callable returned a `Symbol`, or `violate!` was called in `finalize` |
82
250
 
83
- ## Output reshaping (`transform:` / `finalize`)
251
+ Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
84
252
 
85
- The safe replacement for params-mutating before_actions both layers operate on the validated **copy**; the request's `params` is never touched.
253
+ **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).
86
254
 
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.
255
+ **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.
256
+
257
+ ## Custom error messages (`message:`)
258
+
259
+ 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:
260
+
261
+ ```ruby
262
+ permit_params :create, root: :user do
263
+ required :email, :string, format: URI::MailTo::EMAIL_REGEXP,
264
+ message: { missing: "is required", format: "must be a valid email address" }
265
+ optional :age, :integer, in: 18..120, message: "must be between 18 and 120"
266
+ array :tags, of: :string, length: 0..10, message: "must be at most ten tags"
267
+ end
268
+ ```
269
+
270
+ 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:
271
+
272
+ ```json
273
+ { "success": false,
274
+ "error": { "message": "Invalid parameters: user.email must be a valid email address",
275
+ "code": "invalid_parameters",
276
+ "details": [{ "param": "user.email", "code": "format",
277
+ "message": "must be a valid email address" }] } }
278
+ ```
279
+
280
+ The rules:
281
+
282
+ - Messages are written to read after the param name: `"is required"`, not `"Email is required"`.
283
+ - 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" }`.
284
+ - 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.
285
+ - `violate!` in `finalize` takes the same idea as a keyword: `violate!("user.ends_at", :before_start, message: "must be after starts_at")`.
286
+ - A `message:` that is neither a String nor a code → String Hash raises at class load, like every other contract mistake.
287
+
288
+ 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.
289
+
290
+ ## Unknown parameters
291
+
292
+ `unknown:` decides what happens to keys you never declared, **at every nesting level**.
293
+
294
+ | Mode | Behaviour |
295
+ |---|---|
296
+ | `:ignore` (default) | Silently dropped, exactly like strong parameters |
297
+ | `:log` | Dropped, with a `logger.warn` naming the full paths |
298
+ | `:error` | Each undeclared key becomes an `unknown` violation |
299
+
300
+ 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.
301
+
302
+ ## Output reshaping (`transform:` and `finalize`)
303
+
304
+ 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.**
305
+
306
+ ### `transform:` — per field
307
+
308
+ A callable applied **after** cast and validation, reshaping one field's output:
309
+
310
+ ```ruby
311
+ required :tags, :string, transform: ->(v) { v.split(",") }
312
+ ```
313
+
314
+ 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.
315
+
316
+ ### `finalize` — per contract
317
+
318
+ 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.
319
+
320
+ 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
321
 
90
322
  ```ruby
91
323
  permit_params :create, root: :lease_addendum_form do
@@ -93,36 +325,175 @@ permit_params :create, root: :lease_addendum_form do
93
325
  required :signer_names, :string, transform: ->(v) { v.split(",") }
94
326
 
95
327
  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) }
328
+ unless p[:signer_names].length == p[:resident_signatures].length
329
+ violate!("lease_addendum_form.signer_names", :length_mismatch)
330
+ end
331
+
332
+ p[:signatures] = p[:resident_signatures].zip(p[:signer_names]).map do |image, name|
333
+ Signature.new(image: image, full_name: name)
334
+ end
98
335
  p.except(:resident_signatures, :signer_names)
99
336
  end
100
337
  end
101
338
  ```
102
339
 
103
- ## Methods
340
+ Forgetting to return the hash raises an `ArgumentError` telling you exactly that.
341
+
342
+ ## The schema-drift guard
343
+
344
+ 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.
345
+
346
+ Production eager-loads controllers, so a column dropped by a migration **fails the deploy, not the request**:
347
+
348
+ ```
349
+ Permittable: 'nickname' does not exist in the database (table: users).
350
+ Add it with: bin/rails generate migration AddNicknameToUsers nickname:string
351
+ If this parameter is not backed by a column, declare it with virtual: true.
352
+ ```
353
+
354
+ The error carries a ready-to-paste migration command, typed from your own field declaration.
355
+
356
+ - **Fields not backed by a column** — `password_confirmation`, terms checkboxes, search filters — opt out with `virtual: true`.
357
+ - **Nested and array fields are implicitly virtual**, since only scalars map one-to-one onto columns.
358
+ - **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.
359
+
360
+ In CI, one spec calling `Rails.application.eager_load!` exercises every contract in the whole app.
361
+
362
+ ## Sensitive parameters and log redaction
363
+
364
+ 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.
365
+
366
+ ```ruby
367
+ optional :ssn, :string, sensitive: true
368
+ ```
369
+
370
+ 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`.
371
+
372
+ 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.
373
+
374
+ ## Instrumentation
375
+
376
+ Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
377
+
378
+ ```ruby
379
+ ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
380
+ payload[:controller] # "users"
381
+ payload[:action] # "create"
382
+ payload[:details] # [{ param: "user.age", code: "inclusion" }]
383
+ end
384
+ ```
385
+
386
+ ## Exporting OpenAPI (docs that cannot drift)
104
387
 
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`.
388
+ Because a contract is data, it has a third reader beyond the validator and the drift guard: an exporter that emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema). The schema is generated from the same frozen data the server enforces, so — like the drift guard, pointed outward — the docs cannot lie:
110
389
 
111
- ## Semantics worth knowing
390
+ ```sh
391
+ bin/rails permittable:openapi # JSON to stdout
392
+ bin/rails "permittable:openapi[openapi/api.json]" # write to a file
393
+ ```
394
+
395
+ The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
396
+
397
+ Or build fragments programmatically — no Rails required:
112
398
 
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.
399
+ ```ruby
400
+ Permittable::JsonSchema.rule(UsersController.permit_rule_for(:create)) # request-body schema
401
+ Permittable::OpenAPI.request_body_for(UsersController, :create) # OpenAPI requestBody object
402
+ Permittable::OpenAPI.operations_for(UsersController) # { action => operation }
403
+ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
404
+ ```
405
+
406
+ How contracts map:
407
+
408
+ | Contract | Emitted schema |
409
+ |---|---|
410
+ | `required` / `optional` | the object's `required:` array; required strings also get `minLength: 1` (`""` is absent) |
411
+ | `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
412
+ | `:date` / `:datetime` | `string` + `format: date` / `date-time` |
413
+ | `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
414
+ | `in:` Array / numeric Range | `enum` / `minimum` + `maximum` (exclusive ends honoured) |
415
+ | `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
416
+ | `format:` | `pattern`, with `\A`/`\z` translated to `^`/`$` |
417
+ | `default:` / `desc:` / `example:` | `default` / `description` / `examples` |
418
+ | nested block / `array` | `object` + `properties` / `array` + `items` |
419
+ | `unknown: :error` | `additionalProperties: false`, at every nesting level |
420
+ | `root:` | the wrapping object, itself required |
421
+ | `sensitive: true` | `writeOnly: true` (never echoed in responses) |
422
+
423
+ Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
424
+
425
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
426
+
427
+ Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
428
+
429
+ ## API reference
430
+
431
+ ### Instance methods
432
+
433
+ | Method | Purpose |
434
+ |---|---|
435
+ | `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 |
436
+ | `enforce_params_contract` | The `before_action` entry point. Only validates rules declared `enforce: true`. Public, so hosts can `skip_before_action` it |
437
+ | `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
438
+
439
+ ### Class methods
440
+
441
+ | Method | Purpose |
442
+ |---|---|
443
+ | `permit_params(*actions, **opts, &contract)` | Declare a contract |
444
+ | `permittable_contracts` | The frozen array of every declared rule — introspectable, testable |
445
+ | `permit_rule_for(action)` | The last rule matching `action`, or `nil` |
446
+
447
+ ### Module
448
+
449
+ | | |
450
+ |---|---|
451
+ | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
452
+ | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
453
+ | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
454
+ | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
455
+ | `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
456
+
457
+ ## Errors caught at class load
458
+
459
+ 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.
460
+
461
+ - A field declared twice in one contract
462
+ - An unknown option for the field's kind, listing what *is* allowed
463
+ - An unknown type, listing the supported ones
464
+ - An unknown `normalize:` preset, listing the presets
465
+ - `format:`, `length:`, or `normalize:` on a non-`:string` field
466
+ - `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
467
+ - `validate:` or `transform:` that isn't callable
468
+ - A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
469
+ - `required: true` combined with `default:`
470
+ - A field given both a type and a nested block; an array given both `of:` and a block
471
+ - An empty contract, or a nested block declaring no sub-fields
472
+ - `finalize` declared twice, without a block, or inside a nested block
473
+ - `permit_params` without a block, or an invalid `unknown:` mode
474
+ - A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
475
+
476
+ ## Compatibility
477
+
478
+ | | |
479
+ |---|---|
480
+ | Ruby | >= 3.2 |
481
+ | Rails / ActiveSupport | >= 5.0, < 9 |
482
+ | Required dependency | `activesupport` only |
483
+ | Optional | `actionpack` (rendering, `before_action`), `activerecord` (drift guard) |
484
+
485
+ 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
486
 
119
487
  ## Development
120
488
 
121
489
  ```sh
122
490
  bundle install
123
- bundle exec rspec
491
+ bundle exec rspec # 112 examples
492
+ bundle exec rubocop
124
493
  ```
125
494
 
495
+ 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.
496
+
126
497
  ## License
127
498
 
128
- MIT.
499
+ [MIT](LICENSE.txt).