permittable 0.5.0 → 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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +237 -111
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +10 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24ca6a6f3f19fec065cb3ac2edcc255131e0b3e6093b1a0f1b860b31a9798597
|
|
4
|
+
data.tar.gz: 639aa236702384c0762a5ee694835115fdbd9ed677bf16824bff4618f1aa6cb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 852e4234d867bda50477812978c1327132b87e76297fddf9f53d5a62b5e65545bf76bb2955dc2f0deb0fc3a4a547b88ca782a8a12fc03cb3ac7287f78f07c869
|
|
7
|
+
data.tar.gz: 7b850f5b1cc806cf3044c4fb27dcd59f270ff8d0a504ab5fd8e757d9d284df8c03e9182aafffd1e9c538524798e6620b76c621890832e5656dc29b9ae1c4b70a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
14
|
+
## 0.5.1 (2026-09-02)
|
|
15
|
+
<!-- title: nested input outside Rails -->
|
|
16
|
+
|
|
17
|
+
Patch release. Checking how a contract handles a request with several top-level envelopes (`{ user: { ... }, address_attributes: { ... } }`) surfaced one crash and one unhelpful error; both are fixed below, and no behaviour of existing contracts changes.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Nested hash input crashed outside Rails.** `lib/permittable.rb` required `HashWithIndifferentAccess` but not the Hash core extension it needs to convert nested plain Hashes, so a standalone `Permittable::Contract` (or any host that loads only `permittable`) raised `NoMethodError: undefined method 'nested_under_indifferent_access'` on payloads like `{ user: { ... }, address_attributes: { ... } }`. Rails apps and the spec suite loaded the extension indirectly, which is why it went unnoticed; a spec now exercises a nested contract in a bare subprocess.
|
|
21
|
+
- **`root:` rejects anything but one key at class load.** `root: [:user, :address_attributes]` used to leak `NoMethodError: undefined method 'to_sym' for Array`; it now raises a descriptive `ArgumentError` pointing at the recipe for several top-level envelopes — a rootless contract with one nested block per key. Specs pin that recipe, and pin that a rooted contract never sees the root's siblings (even under `unknown: :error`), matching `require(:user).permit`.
|
|
22
|
+
|
|
3
23
|
## 0.5.0 (2026-09-02)
|
|
4
24
|
<!-- title: the adoption on-ramp -->
|
|
5
25
|
|
data/README.md
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
-
[](https://github.com/VSN2015/permittable/actions/workflows/ci.yml)
|
|
5
|
-
[](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.
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
136
|
+
## Quick start
|
|
137
|
+
|
|
138
|
+
**1. Add the gem**
|
|
84
139
|
|
|
85
140
|
```ruby
|
|
86
141
|
gem "permittable"
|
|
87
142
|
```
|
|
88
143
|
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
+
### The field DSL
|
|
151
255
|
|
|
152
|
-
|
|
256
|
+
Three verbs. `required` and `optional` declare scalars (or, with a block, nested hashes); `array` declares a list.
|
|
153
257
|
|
|
154
258
|
```ruby
|
|
155
|
-
|
|
259
|
+
# Scalars — the type defaults to :string
|
|
260
|
+
required :name, :string
|
|
156
261
|
optional :age, :integer
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### Nested hashes
|
|
160
262
|
|
|
161
|
-
|
|
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
|
-
|
|
175
|
-
array :tag_names,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
554
|
+
### Generating draft contracts (`permittable:generate`)
|
|
453
555
|
|
|
454
|
-
The blank-page problem, solved: the first draft of every contract
|
|
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
|
-
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## Beyond the controller
|
|
485
589
|
|
|
486
|
-
|
|
590
|
+
Because a contract is data, it has readers other than the request validator.
|
|
487
591
|
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
+
### Exporting OpenAPI (docs that cannot drift)
|
|
536
642
|
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
687
|
+
</details>
|
|
573
688
|
|
|
574
|
-
|
|
689
|
+
---
|
|
575
690
|
|
|
576
|
-
|
|
691
|
+
## Reference
|
|
577
692
|
|
|
578
|
-
|
|
693
|
+
### API
|
|
579
694
|
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "active_support"
|
|
|
2
2
|
require "active_support/concern"
|
|
3
3
|
require "active_support/notifications"
|
|
4
4
|
require "active_support/hash_with_indifferent_access"
|
|
5
|
+
require "active_support/core_ext/hash/indifferent_access" # nested plain Hashes inside HWIA.new
|
|
5
6
|
require "active_support/core_ext/class/attribute"
|
|
6
7
|
require "active_support/core_ext/string/inflections"
|
|
7
8
|
require "active_support/core_ext/string/filters"
|
|
@@ -611,6 +612,10 @@ module Permittable
|
|
|
611
612
|
#
|
|
612
613
|
# root: key to unwrap first (`require(:user)` equivalent); false
|
|
613
614
|
# (default) reads top-level params. Missing root renders 400.
|
|
615
|
+
# Exactly one key: a rooted contract never sees the root's
|
|
616
|
+
# siblings (like `require(:user).permit`), so to accept
|
|
617
|
+
# several top-level envelopes stay rootless and declare one
|
|
618
|
+
# nested block per key.
|
|
614
619
|
# model: a model class (or `true` to infer from controller_name)
|
|
615
620
|
# enabling the schema-drift check on every non-virtual scalar
|
|
616
621
|
# field.
|
|
@@ -627,6 +632,11 @@ module Permittable
|
|
|
627
632
|
def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &block)
|
|
628
633
|
raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
|
|
629
634
|
|
|
635
|
+
unless root.nil? || root == false || root.is_a?(Symbol) || root.is_a?(String)
|
|
636
|
+
raise ArgumentError, "#{LABEL}: :root must be one key (Symbol or String) or false, got #{root.inspect} — " \
|
|
637
|
+
"to accept several top-level keys, declare a rootless contract with one nested block per key"
|
|
638
|
+
end
|
|
639
|
+
|
|
630
640
|
unknown = unknown.to_sym
|
|
631
641
|
raise ArgumentError, "#{LABEL}: :unknown must be one of #{UNKNOWN_MODES.join(', ')}" unless UNKNOWN_MODES.include?(unknown)
|
|
632
642
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-09-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|