permittable 0.5.1 → 0.5.2

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: d9b826995301bd0dc72ab69a6560ddfe58e13ae67323a252a9a506c21f61420d
4
- data.tar.gz: 5e354c9e104237d9116591df08d4c1a58962f98d543d0f5ff6de983b96f3bc51
3
+ metadata.gz: 24ca6a6f3f19fec065cb3ac2edcc255131e0b3e6093b1a0f1b860b31a9798597
4
+ data.tar.gz: 639aa236702384c0762a5ee694835115fdbd9ed677bf16824bff4618f1aa6cb9
5
5
  SHA512:
6
- metadata.gz: c9cf62a2210a42cad9190f25ce259250c268dabf72bb966bae4110d2c627f27ce7a050a64f92c9e1729c9eb088d48077384b901481475a5cae077d1d802684ab
7
- data.tar.gz: a6db0156d761c972ef0c9f6b41ef96e3fee3f0f48158ce01344b8a6ea39f9a8504dd2df015bcf3c379841420a66e857a42a2d0ff07d2d94902b49d89fec6b570
6
+ metadata.gz: 852e4234d867bda50477812978c1327132b87e76297fddf9f53d5a62b5e65545bf76bb2955dc2f0deb0fc3a4a547b88ca782a8a12fc03cb3ac7287f78f07c869
7
+ data.tar.gz: 7b850f5b1cc806cf3044c4fb27dcd59f270ff8d0a504ab5fd8e757d9d284df8c03e9182aafffd1e9c538524798e6620b76c621890832e5656dc29b9ae1c4b70a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.5.2 (2026-09-06)
4
+ <!-- title: Railtie coverage and a new README -->
5
+
6
+ Patch release with no behaviour changes: the boot-time integration gets its first real spec, and the README was rewritten from the ground up.
7
+
8
+ ### Added
9
+ - **`spec/railtie_spec.rb` — the boot-time integration is now covered.** Everything `Permittable::Railtie` does happens during a Rails boot, and none of it had a spec: the `filter_parameters` wiring, its reach into ActiveRecord, or the rake tasks. Unit specs cannot see any of it, and the `FakeController` harness deliberately has no Rails at all. The new spec boots a **real Rails application in a subprocess** — the same approach the "without Rails loaded" specs use, and for the same reason: a boot mutates global state (`Rails.application` is a singleton, initializers run once) and must not leak into the rest of the suite. It asserts that exactly one filter proc is appended without disturbing the app's own entries, that a `sensitive:` field declared by a controller loaded **after** boot is redacted (the whole reason for a live registry rather than appended symbols), that the proc reaches `ActiveRecord::Base.filter_attributes` so a model's `#inspect` redacts too, and that both rake tasks load. `railties` joins the dev bundle for it, and the spec skips rather than fails where railties is absent, so a host without Rails — and the compatibility gemfiles that omit it — are unaffected.
10
+
11
+ ### Changed
12
+ - **README rewritten as a navigable overview.** A badges-and-nav header, a table of contents, and sections grouped into Guide, Adopting on a live API, Beyond the controller, and Reference; a motivating before/after example; a diagram of how one contract feeds validation, the drift guard, OpenAPI export, and the RSpec matchers; and the long enumerations (class-load errors, the schema mapping table) folded into `<details>` blocks. Documentation only.
13
+
3
14
  ## 0.5.1 (2026-09-02)
4
15
  <!-- title: nested input outside Rails -->
5
16
 
data/README.md CHANGED
@@ -1,14 +1,30 @@
1
- # Permittable
1
+ <h1 align="center">Permittable</h1>
2
+
3
+ <p align="center"><strong>Strong parameters for Rails that also know types, bounds, and defaults.</strong></p>
4
+
5
+ <p align="center">
6
+ <a href="https://rubygems.org/gems/permittable"><img src="https://img.shields.io/gem/v/permittable.svg" alt="Gem Version"></a>
7
+ <a href="https://github.com/VSN2015/permittable/actions/workflows/ci.yml"><img src="https://github.com/VSN2015/permittable/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
8
+ <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%E2%89%A5%203.2-CC342D.svg" alt="Ruby >= 3.2"></a>
9
+ <a href="https://rubyonrails.org/"><img src="https://img.shields.io/badge/rails-5.0%20%E2%80%93%208.x-D30001.svg" alt="Rails 5.0 - 8.x"></a>
10
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="#quick-start">Quick start</a> ·
15
+ <a href="#guide">Guide</a> ·
16
+ <a href="#adopting-on-a-live-api">Adopting on a live API</a> ·
17
+ <a href="#beyond-the-controller">Beyond the controller</a> ·
18
+ <a href="#reference">Reference</a> ·
19
+ <a href="docs/comparison.md">Comparison</a> ·
20
+ <a href="CHANGELOG.md">Changelog</a>
21
+ </p>
2
22
 
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)
6
-
7
- **Strong parameters for Rails that also know types, bounds, and defaults.**
23
+ ---
8
24
 
9
25
  `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
26
 
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.
27
+ A Permittable **contract** answers those questions too. Declared once on the controller class, 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
28
 
13
29
  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.
14
30
 
@@ -44,26 +60,33 @@ A violating request never reaches your action:
44
60
  "details": [{ "param": "user.age", "code": "inclusion" }] } }
45
61
  ```
46
62
 
47
- ---
63
+ ## Why
48
64
 
49
- ## Contents
65
+ Here is what the contract above replaces. Every Rails codebase has a version of this, and no two of them agree on the error shape:
50
66
 
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
- - [Monitor mode](#monitor-mode-roll-out-without-rejecting) · [Generating draft contracts](#generating-draft-contracts-permittablegenerate) · [Testing contracts](#testing-contracts-rspec-matchers)
58
- - [Standalone contracts](#standalone-contracts-no-controller) · [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
59
- - [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
67
+ ```ruby
68
+ def create
69
+ attrs = params.require(:user).permit(:name, :email, :age, :plan)
70
+
71
+ if attrs[:age].present?
72
+ age = Integer(attrs[:age], exception: false)
73
+ return render(json: { error: "age must be a number" }, status: 422) if age.nil?
74
+ return render(json: { error: "age must be 18..120" }, status: 422) unless (18..120).cover?(age)
75
+ attrs[:age] = age
76
+ end
77
+ unless attrs[:email].to_s.match?(URI::MailTo::EMAIL_REGEXP)
78
+ return render(json: { error: "email is invalid" }, status: 422)
79
+ end
80
+ attrs[:plan] = "free" if attrs[:plan].blank?
60
81
 
61
- ---
82
+ User.create!(attrs)
83
+ end
84
+ ```
62
85
 
63
- ## Why
86
+ A contract moves all of it out of the action and into data that the rest of your toolchain can read:
64
87
 
65
88
  | | `params.permit` | `params.expect` (Rails 8) | Permittable |
66
- |---|---|---|---|
89
+ |---|:---:|:---:|:---:|
67
90
  | Filters unknown keys | ✅ | ✅ | ✅ |
68
91
  | Requires a root key | via `require` | ✅ | ✅ |
69
92
  | Casts to a declared type | ❌ | ❌ | ✅ |
@@ -75,18 +98,50 @@ A violating request never reaches your action:
75
98
  | Exports OpenAPI / JSON Schema | ❌ | ❌ | ✅ |
76
99
  | Report-only rollout mode | ❌ | ❌ | ✅ |
77
100
  | Drafts contracts from your schema | ❌ | ❌ | ✅ |
101
+ | Works outside controllers | ❌ | ❌ | ✅ |
102
+
103
+ Every option in this space is good software, and Permittable is not always the right one. A longer, honest comparison — `params.expect`, rails_param, dry-validation, typed_params, rswag, the cases where each is the better choice, benchmarks, and migration costs — lives in [docs/comparison.md](docs/comparison.md).
104
+
105
+ ## One idea: a contract is data
106
+
107
+ Everything in this gem follows from a single decision. A contract is **declared once at the class level, frozen, inheritable, and introspectable**. It is not code that runs inside your action. That makes it readable by more than the validator:
78
108
 
79
- 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.
109
+ ```
110
+ ┌────────────────────────────────────────────────────┐
111
+ │ permit_params :create, root: :user, model: User │
112
+ │ required :email, :string, format: EMAIL_REGEXP │
113
+ │ optional :age, :integer, in: 18..120 │
114
+ │ end │
115
+ └──────────────────────────┬─────────────────────────┘
116
+ one frozen, class-level contract
117
+
118
+ ┌─────────────────┬─────────────────┼─────────────────┬─────────────────┐
119
+ ▼ ▼ ▼ ▼ ▼
120
+ Validator Drift guard OpenAPI RSpec matchers Contract
121
+ casts, checks, compares fields 3.1 export from assert on the the same DSL on
122
+ defaults; a 422 to DB columns the same data rule itself, no any Hash, no
123
+ names the at class load; the server request needed controller
124
+ parameter fails the deploy enforces required
125
+ ```
126
+
127
+ The principles that fall out of it:
80
128
 
81
- A longer, honest comparison `params.expect`, rails_param, dry-validation, typed_params, rswag, with the cases where each of them is the better choice, plus benchmarks and migration costs — lives in [docs/comparison.md](docs/comparison.md).
129
+ - **Strict, never lenient.** `"abc"` is never `0`. A value the type cannot faithfully represent is a violation, not a guess.
130
+ - **`nil` and `""` are absent.** Absent optionals are omitted from the result, so partial updates never nil-out columns.
131
+ - **Mistakes fail at class load.** A malformed contract, a default that violates its own field, or a column that no longer exists fails the boot, never the request.
132
+ - **The request's `params` is never mutated.** Reshaping happens on the validated copy.
133
+ - **Exports never guess.** Anything JSON Schema cannot represent stays visible as an `x-permittable-*` extension instead of being mistranslated.
134
+ - **One dependency.** `activesupport` is the only runtime requirement. Rails, ActionPack, and ActiveRecord are optional integration points.
82
135
 
83
- ## Installation
136
+ ## Quick start
137
+
138
+ **1. Add the gem**
84
139
 
85
140
  ```ruby
86
141
  gem "permittable"
87
142
  ```
88
143
 
89
- Then include it wherever you need it — typically once in `ApplicationController`:
144
+ **2. Include it once**
90
145
 
91
146
  ```ruby
92
147
  class ApplicationController < ActionController::Base
@@ -94,28 +149,77 @@ class ApplicationController < ActionController::Base
94
149
  end
95
150
  ```
96
151
 
97
- 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.
152
+ **3. Declare a contract and read `permitted_params`**
153
+
154
+ ```ruby
155
+ class OrdersController < ApplicationController
156
+ permit_params :create, root: :order, model: Order do
157
+ required :sku, :string
158
+ optional :quantity, :integer, in: 1..99, default: 1
159
+ optional :notes, :string, length: 0..500
160
+ end
161
+
162
+ def create
163
+ Order.create!(permitted_params)
164
+ end
165
+ end
166
+ ```
167
+
168
+ That is the whole integration. Violations render the 422 envelope automatically, `model: Order` verifies the fields against the `orders` table when the class loads, and every rejected request emits an `invalid_parameters.permittable` notification.
169
+
170
+ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live API](#adopting-on-a-live-api): the gem can draft the contracts for you, and run them in a report-only mode until you are ready to enforce.
98
171
 
99
172
  > **Naming note:** some legacy stacks (InheritedResources) define their own `permitted_params`. Don't include both on one controller.
100
173
 
101
- ## How a request flows
174
+ ## Contents
175
+
176
+ - **[Guide](#guide)**
177
+ - [How a request flows](#how-a-request-flows)
178
+ - [Declaring a contract](#declaring-a-contract)
179
+ - [The field DSL](#the-field-dsl)
180
+ - [Field options](#field-options)
181
+ - [Types and strict coercion](#types-and-strict-coercion)
182
+ - [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
183
+ - [Violations and error responses](#violations-and-error-responses)
184
+ - [Custom error messages](#custom-error-messages-message) · [Localizing with I18n](#localizing-default-messages-i18n)
185
+ - [Unknown parameters](#unknown-parameters)
186
+ - [Output reshaping](#output-reshaping-transform-and-finalize)
187
+ - [The schema-drift guard](#the-schema-drift-guard)
188
+ - [Sensitive parameters and log redaction](#sensitive-parameters-and-log-redaction)
189
+ - [Instrumentation](#instrumentation)
190
+ - **[Adopting on a live API](#adopting-on-a-live-api)**
191
+ - [Monitor mode](#monitor-mode-roll-out-without-rejecting)
192
+ - [Generating draft contracts](#generating-draft-contracts-permittablegenerate)
193
+ - **[Beyond the controller](#beyond-the-controller)**
194
+ - [Testing contracts](#testing-contracts-rspec-matchers)
195
+ - [Standalone contracts](#standalone-contracts-no-controller)
196
+ - [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
197
+ - **[Reference](#reference)**
198
+ - [API](#api) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
199
+ - [Development](#development) · [License](#license)
200
+
201
+ ---
202
+
203
+ ## Guide
204
+
205
+ ### How a request flows
102
206
 
103
207
  ```
104
- params
105
-
106
- ├─ 1. unwrap root: params[:user] → 400 if missing or not a hash
107
- ├─ 2. per field: normalize → cast → validate → transform
108
- ├─ 3. unknown-key check at every nesting level
109
- ├─ 4. finalize only if nothing violated
110
-
111
- └─ permitted_params HashWithIndifferentAccess (or raises InvalidParameters)
208
+ request params
209
+
210
+ ├─ 1 unwrap root: params[:user] missing or not a hash → 400
211
+ ├─ 2 each field normalize → cast → validate → transform
212
+ ├─ 3 unknown-key check at every nesting level (unknown: :ignore | :log | :error)
213
+ ├─ 4 finalize only when nothing violated
214
+
215
+ └─ permitted_params HashWithIndifferentAccess or raises InvalidParameters → 422
112
216
  ```
113
217
 
114
218
  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**.
115
219
 
116
220
  In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
117
221
 
118
- ## Declaring a contract
222
+ ### Declaring a contract
119
223
 
120
224
  ```ruby
121
225
  permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &contract)
@@ -147,39 +251,32 @@ end
147
251
 
148
252
  Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
149
253
 
150
- ## The field DSL
254
+ ### The field DSL
151
255
 
152
- ### Scalars
256
+ Three verbs. `required` and `optional` declare scalars (or, with a block, nested hashes); `array` declares a list.
153
257
 
154
258
  ```ruby
155
- required :name, :string # type defaults to :string
259
+ # Scalars the type defaults to :string
260
+ required :name, :string
156
261
  optional :age, :integer
157
- ```
158
-
159
- ### Nested hashes
160
262
 
161
- Pass a block instead of a type. Violation paths are dotted (`user.address.zip`).
162
-
163
- ```ruby
263
+ # Nested hashes — pass a block instead of a type. Violation paths are dotted: user.address.zip
164
264
  optional :address do
165
265
  required :city, :string
166
266
  optional :zip, :string, format: /\A\d{5}\z/
167
267
  end
168
- ```
169
-
170
- ### Arrays
171
-
172
- `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]`).
173
268
 
174
- ```ruby
175
- array :tag_names, of: :string, length: 0..10
269
+ # Arrays — of: for scalars, a block for hashes. Element failures carry their index: items[1].sku
270
+ array :tag_names, of: :string, length: 0..10
176
271
  array :line_items, required: true do
177
272
  required :sku, :string
178
273
  required :quantity, :integer, in: 1..99
179
274
  end
180
275
  ```
181
276
 
182
- ## Field options
277
+ Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
278
+
279
+ ### Field options
183
280
 
184
281
  Which options are legal depends on the field kind — anything else raises at class load.
185
282
 
@@ -208,7 +305,7 @@ Which options are legal depends on the field kind — anything else raises at cl
208
305
  optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
209
306
  ```
210
307
 
211
- ## Types and strict coercion
308
+ ### Types and strict coercion
212
309
 
213
310
  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**.
214
311
 
@@ -227,23 +324,25 @@ Two behaviours worth committing to memory:
227
324
  - **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.
228
325
  - **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.
229
326
 
230
- ## Absence, defaults, and partial updates
327
+ ### Absence, defaults, and partial updates
231
328
 
232
329
  `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.
233
330
 
234
331
  That single rule produces the behaviour you want from a `PATCH`:
235
332
 
236
- - An **absent optional field is omitted** from the result, so partial updates never nil-out columns.
237
- - An **absent required field violates** with `missing`.
238
- - 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.)
333
+ | The field is | Result |
334
+ |---|---|
335
+ | absent and **optional** | omitted from the result, so partial updates never nil-out columns |
336
+ | absent and **required** | a `missing` violation |
337
+ | absent with a **`default:`** | the default — a defaulted field can never report `missing` |
239
338
 
240
- 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.
339
+ Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality. And 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.
241
340
 
242
341
  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.
243
342
 
244
- ## Violations and error responses
343
+ ### Violations and error responses
245
344
 
246
- 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.
345
+ 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 shown at the [top of this README](#permittable).
247
346
 
248
347
  | Code | Raised when |
249
348
  |---|---|
@@ -258,11 +357,11 @@ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an ar
258
357
 
259
358
  Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
260
359
 
261
- **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).
360
+ **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.
262
361
 
263
- **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.
362
+ **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 is rendered. Either way, `render_invalid_parameters` is a normal method you can override. For full control over the body (RFC 9457, a different envelope), `error.details` gives you the structured violations to build from.
264
363
 
265
- ## Custom error messages (`message:`)
364
+ ### Custom error messages (`message:`)
266
365
 
267
366
  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:
268
367
 
@@ -293,7 +392,7 @@ The rules:
293
392
  - `violate!` in `finalize` takes the same idea as a keyword: `violate!("user.ends_at", :before_start, message: "must be after starts_at")`.
294
393
  - A `message:` that is neither a String nor a code → String Hash raises at class load, like every other contract mistake.
295
394
 
296
- ### Localizing default messages (I18n)
395
+ #### Localizing default messages (I18n)
297
396
 
298
397
  App-wide copy for a violation code — without repeating `message:` on every field — comes from I18n, under `permittable.errors.<code>`:
299
398
 
@@ -310,9 +409,7 @@ en:
310
409
 
311
410
  Resolution order per violation: the field's own `message:` (String, or the Hash entry for that code) → the app's `permittable.errors.<code>` translation → the bare `{ param:, code: }` shape. The lookup also covers a missing `root:`, `unknown` keys, Symbol codes returned by `validate:` (`permittable.errors.must_be_even`), and `violate!` codes in `finalize` (an explicit `violate!(..., message:)` still wins). Only a String translation counts — a missing key or a nested Hash falls back to the bare shape rather than leaking structure to clients. No I18n, no change: apps without the gem or the keys behave exactly as before.
312
411
 
313
- For full control over the response body itself (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.
314
-
315
- ## Unknown parameters
412
+ ### Unknown parameters
316
413
 
317
414
  `unknown:` decides what happens to keys you never declared, **at every nesting level**.
318
415
 
@@ -324,13 +421,11 @@ For full control over the response body itself (RFC 9457, a different envelope),
324
421
 
325
422
  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.
326
423
 
327
- ## Output reshaping (`transform:` and `finalize`)
424
+ ### Output reshaping (`transform:` and `finalize`)
328
425
 
329
426
  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.**
330
427
 
331
- ### `transform:` — per field
332
-
333
- A callable applied **after** cast and validation, reshaping one field's output:
428
+ **`transform:` — per field.** A callable applied **after** cast and validation, reshaping one field's output:
334
429
 
335
430
  ```ruby
336
431
  required :tags, :string, transform: ->(v) { v.split(",") }
@@ -338,11 +433,9 @@ required :tags, :string, transform: ->(v) { v.split(",") }
338
433
 
339
434
  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.
340
435
 
341
- ### `finalize` — per contract
436
+ **`finalize` — per contract.** 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.
342
437
 
343
- 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.
344
-
345
- 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).
438
+ 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 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).
346
439
 
347
440
  ```ruby
348
441
  permit_params :create, root: :lease_addendum_form do
@@ -364,7 +457,7 @@ end
364
457
 
365
458
  Forgetting to return the hash raises an `ArgumentError` telling you exactly that.
366
459
 
367
- ## The schema-drift guard
460
+ ### The schema-drift guard
368
461
 
369
462
  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.
370
463
 
@@ -384,7 +477,7 @@ The error carries a ready-to-paste migration command, typed from your own field
384
477
 
385
478
  In CI, one spec calling `Rails.application.eager_load!` exercises every contract in the whole app.
386
479
 
387
- ## Sensitive parameters and log redaction
480
+ ### Sensitive parameters and log redaction
388
481
 
389
482
  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.
390
483
 
@@ -396,7 +489,7 @@ The indirection is deliberate. Appending plain symbols to `config.filter_paramet
396
489
 
397
490
  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.
398
491
 
399
- ## Instrumentation
492
+ ### Instrumentation
400
493
 
401
494
  Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
402
495
 
@@ -404,13 +497,29 @@ Every violation emits an `ActiveSupport::Notifications` event, so rejected reque
404
497
  ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
405
498
  payload[:controller] # "users"
406
499
  payload[:action] # "create"
500
+ payload[:mode] # :enforce, or :monitor for a would-be rejection
407
501
  payload[:details] # [{ param: "user.age", code: "inclusion" }]
408
502
  end
409
503
  ```
410
504
 
411
- ## Monitor mode (roll out without rejecting)
505
+ ---
506
+
507
+ ## Adopting on a live API
508
+
509
+ Adding contracts to an API with real traffic has a chicken-and-egg problem: you cannot know what the 422s would break until you enforce them, and you dare not enforce them until you know. Old mobile app versions, third-party integrations, and forgotten cron jobs all send what they send.
510
+
511
+ Permittable's answer is an afternoon-sized recipe:
412
512
 
413
- Adopting contracts on a live API or tightening one field on an existing contract has a chicken-and-egg problem: you cannot know what the 422s would break until you enforce them, and you dare not enforce them until you know. Old mobile app versions, third-party integrations, and forgotten cron jobs all send what they send. `mode: :monitor` resolves it: the full pipeline runs (unwrap, cast, validate, defaults), but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed.
513
+ 1. **Draft.** `bin/rails permittable:generate` writes a first contract for every controller from the model's columns and the `params.permit` calls already in the source. Action code stays as-is.
514
+ 2. **Monitor.** Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; every would-be rejection is logged and instrumented.
515
+ 3. **Watch.** Point your existing notification subscriber at a dashboard. Every entry is a real client that would have been rejected — fix the contract, or wait for that traffic to drain.
516
+ 4. **Enforce.** Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
517
+
518
+ The two halves of that recipe are below.
519
+
520
+ ### Monitor mode (roll out without rejecting)
521
+
522
+ `mode: :monitor` runs the full pipeline — unwrap, cast, validate, defaults — but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed.
414
523
 
415
524
  ```ruby
416
525
  class OrdersController < ApplicationController
@@ -434,24 +543,17 @@ Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym
434
543
  On a violating request in monitor mode:
435
544
 
436
545
  - **Nothing raises and nothing renders** — the action runs.
437
- - The [`invalid_parameters.permittable` event](#instrumentation) fires with `mode: :monitor` in the payload (enforced violations carry `mode: :enforce`), and the logger warns with the offending paths. Point your existing subscriber at a dashboard and you have a per-controller rollout report.
438
- - `permitted_params` returns the **raw pass-through**: exactly what the client sent, untouched — no casts, no defaults, no transforms. A missing `root:` passes an empty hash (the envelope you asked for isn't there); a rootless contract drops only Rails' routing keys.
546
+ - The [`invalid_parameters.permittable` event](#instrumentation) fires with `mode: :monitor` in the payload (enforced violations carry `mode: :enforce`), and the logger warns with the offending paths.
547
+ - `permitted_params` returns the **raw pass-through**: exactly what the client sent, untouched — no casts, no defaults, no transforms. A missing `root:` passes an empty hash; a rootless contract drops only Rails' routing keys.
439
548
  - `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
440
549
 
441
550
  Monitor-mode rules validate **eagerly in the `before_action`, regardless of `enforce:`** — telemetry must not depend on the action calling `permitted_params`, since legacy actions still reading `params` directly are exactly the ones worth monitoring. (On a plain-Ruby host without `before_action`, validation stays lazy.)
442
551
 
443
- The rollout recipe:
444
-
445
- 1. Write contracts for a legacy controller — or let [`permittable:generate`](#generating-draft-contracts-permittablegenerate) draft them. The action code stays as-is.
446
- 2. Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; telemetry starts.
447
- 3. Watch the dashboard. Every entry is a real client that would have been rejected — fix the contract, or wait for that traffic to drain.
448
- 4. Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
449
-
450
552
  [Exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) marks operations whose rule declares `mode: :monitor` with `x-permittable-mode: "monitor"` — the docs shouldn't promise a 422 the server doesn't yet send. Only the per-rule declaration is exported: the global `Permittable.mode` is runtime configuration, not contract data.
451
553
 
452
- ## Generating draft contracts (`permittable:generate`)
554
+ ### Generating draft contracts (`permittable:generate`)
453
555
 
454
- The blank-page problem, solved: the first draft of every contract can be generated from what the app already knows — the model's columns, and the `params.permit` calls already sitting in the controller.
556
+ The blank-page problem, solved: the first draft of every contract is generated from what the app already knows.
455
557
 
456
558
  ```sh
457
559
  bin/rails permittable:generate # every controller without a contract
@@ -481,11 +583,15 @@ The generator's one rule is **draft, don't guess** — everything it cannot know
481
583
 
482
584
  No Rails required for the core: `Permittable::Generator.draft(model: User)`, `.for_controller(controller, source: File.read(path))`, and `.scan(source)` are plain Ruby.
483
585
 
484
- Together with [monitor mode](#monitor-mode-roll-out-without-rejecting) this makes the whole adoption path one afternoon: generate drafts, paste, deploy monitoring, watch the dashboard, flip to enforce.
586
+ ---
587
+
588
+ ## Beyond the controller
485
589
 
486
- ## Testing contracts (RSpec matchers)
590
+ Because a contract is data, it has readers other than the request validator.
487
591
 
488
- Because a contract is data, it can be specified without dispatching a request. `require "permittable/rspec"` (in `spec_helper.rb`) auto-includes the matchers:
592
+ ### Testing contracts (RSpec matchers)
593
+
594
+ A contract can be specified without dispatching a request. `require "permittable/rspec"` (in `spec_helper.rb`) auto-includes the matchers:
489
595
 
490
596
  ```ruby
491
597
  RSpec.describe UsersController do
@@ -505,7 +611,7 @@ Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (
505
611
 
506
612
  `for_action` picks the rule exactly like a request would (`permit_rule_for`), and may be omitted only when the controller declares a single contract — an ambiguous expectation raises instead of silently checking the wrong rule. Failure messages name what the contract actually declares.
507
613
 
508
- ## Standalone contracts (no controller)
614
+ ### Standalone contracts (no controller)
509
615
 
510
616
  The same DSL, callable on any Hash — webhook payloads, job arguments, service-object inputs, CSV rows:
511
617
 
@@ -532,9 +638,9 @@ Everything carries over — strict coercion, `""`/`nil` absence, defaults, `fina
532
638
  - **No router-key exemption.** `unknown: :error` flags a stray `action` or `controller` key — standalone input has no router to excuse.
533
639
  - **No memoization.** Every `#call` validates fresh, so one frozen contract is safely reusable and shareable (assign it to a constant).
534
640
 
535
- ## Exporting OpenAPI (docs that cannot drift)
641
+ ### Exporting OpenAPI (docs that cannot drift)
536
642
 
537
- 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:
643
+ An exporter emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema) from the same frozen data the server enforces. Like the drift guard pointed outward: the docs cannot lie.
538
644
 
539
645
  ```sh
540
646
  bin/rails permittable:openapi # JSON to stdout
@@ -552,7 +658,16 @@ Permittable::OpenAPI.operations_for(UsersController) # { acti
552
658
  Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
553
659
  ```
554
660
 
555
- How contracts map:
661
+ 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.
662
+
663
+ **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 whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; 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.
664
+
665
+ 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.
666
+
667
+ <details>
668
+ <summary><strong>How contracts map onto JSON Schema</strong></summary>
669
+
670
+ <br>
556
671
 
557
672
  | Contract | Emitted schema |
558
673
  |---|---|
@@ -569,15 +684,15 @@ How contracts map:
569
684
  | `root:` | the wrapping object, itself required |
570
685
  | `sensitive: true` | `writeOnly: true` (never echoed in responses) |
571
686
 
572
- 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.
687
+ </details>
573
688
 
574
- **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 whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; 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.
689
+ ---
575
690
 
576
- 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.
691
+ ## Reference
577
692
 
578
- ## API reference
693
+ ### API
579
694
 
580
- ### Instance methods
695
+ **Instance methods**
581
696
 
582
697
  | Method | Purpose |
583
698
  |---|---|
@@ -586,7 +701,7 @@ Output is deterministic (fixed key order, declaration-order properties), so the
586
701
  | `enforce_params_contract` | The `before_action` entry point. Validates rules declared `enforce: true` and all [monitor-mode](#monitor-mode-roll-out-without-rejecting) rules. Public, so hosts can `skip_before_action` it |
587
702
  | `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
588
703
 
589
- ### Class methods
704
+ **Class methods**
590
705
 
591
706
  | Method | Purpose |
592
707
  |---|---|
@@ -594,9 +709,9 @@ Output is deterministic (fixed key order, declaration-order properties), so the
594
709
  | `permittable_contracts` | The frozen array of every declared rule — introspectable, testable |
595
710
  | `permit_rule_for(action)` | The last rule matching `action`, or `nil` |
596
711
 
597
- ### Module
712
+ **Module**
598
713
 
599
- | | |
714
+ | Constant | Purpose |
600
715
  |---|---|
601
716
  | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
602
717
  | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
@@ -608,10 +723,15 @@ Output is deterministic (fixed key order, declaration-order properties), so the
608
723
  | `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`) |
609
724
  | `Permittable::Matchers` | RSpec matchers via `require "permittable/rspec"` — see [testing contracts](#testing-contracts-rspec-matchers) |
610
725
 
611
- ## Errors caught at class load
726
+ ### Errors caught at class load
612
727
 
613
728
  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.
614
729
 
730
+ <details>
731
+ <summary><strong>The full list</strong></summary>
732
+
733
+ <br>
734
+
615
735
  - A field declared twice in one contract
616
736
  - An unknown option for the field's kind, listing what *is* allowed
617
737
  - An unknown type, listing the supported ones
@@ -625,26 +745,32 @@ A bad contract is a programmer error, so it fails when the class loads — never
625
745
  - An empty contract, or a nested block declaring no sub-fields
626
746
  - `finalize` declared twice, without a block, or inside a nested block
627
747
  - `permit_params` without a block, or an invalid `unknown:` mode
748
+ - A `root:` that isn't a single key (several top-level envelopes are a rootless contract with one nested block per key)
628
749
  - An invalid `mode:` (and `Permittable.mode =` rejects invalid values at assignment)
629
750
  - A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
630
751
 
631
- ## Compatibility
752
+ </details>
632
753
 
633
- | | |
754
+ ### Compatibility
755
+
756
+ | Requirement | Supported |
634
757
  |---|---|
635
758
  | Ruby | >= 3.2 |
636
759
  | Rails / ActiveSupport | >= 5.0, < 9 |
637
760
  | Required dependency | `activesupport` only |
638
761
  | Optional | `actionpack` (rendering, `before_action`), `activerecord` (drift guard) |
639
762
 
763
+ `actionpack` and `activerecord` are optional because every touchpoint is guarded with `respond_to?`/`defined?` — 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.
764
+
640
765
  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.
641
766
 
642
767
  ## Development
643
768
 
644
769
  ```sh
645
770
  bundle install
646
- bundle exec rspec # 125 examples
771
+ bundle exec rspec # the suite, with a coverage report in coverage/
647
772
  bundle exec rubocop
773
+ bundle exec ruby benchmark/overhead.rb # a full contract vs. the params.permit call it replaces
648
774
  ```
649
775
 
650
776
  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.
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.5.1".freeze
2
+ VERSION = "0.5.2".freeze
3
3
  end
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.5.1
4
+ version: 0.5.2
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-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport