permittable 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +311 -114
- data/lib/permittable/generator.rb +8 -5
- data/lib/permittable/json_schema.rb +33 -0
- data/lib/permittable/rspec.rb +7 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +176 -17
- 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: ddca88b2da7672d0995e338ab901188c4ba019f4d5928d7aaf0a5f73a7f8ba9c
|
|
4
|
+
data.tar.gz: 1a66ef88adc7ab843798f25c7a5e3444a3e550795d1353139d52b65e05ac5005
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7288c663fd8a2d740ca1c1e7fe9ad61d50854fa47e0e9c4a75bb0922bd119d7c4ca9fe99b020b6ea03edfb456d0fe88322161d53cc1bdd71afb6b88f6cf6ccda
|
|
7
|
+
data.tar.gz: a9ecd5c620fac068991a0401d76f339163eb20b6bed73ab83a33f5c3895f8bfb82479a86ab9d6385d85321732ce8ebc6c332819b32174bc8d0494c6021d1623f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.6.0 (2026-09-08)
|
|
4
|
+
<!-- title: nullable fields, :json, and strict dates -->
|
|
5
|
+
|
|
6
|
+
Two gaps in the field vocabulary closed and one guess removed. A contract can now say *clear this column* (`nullable:`) and *this hash has no shape, but it has bounds* (`:json`), and a date string must name the whole date instead of borrowing the missing parts from today. Contracts that use neither new option see only the date-parsing change, which is called out below.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`:json` field type — free-form hashes, the `jsonb` column case.** A `json`/`jsonb` column exists precisely so its contents need no schema, and every other field kind describes a shape. Until now a contract had only bad options for one: declare sub-keys you don't know, or leave the key undeclared — in which case the contract **silently dropped it** and the column never saw the data. Strong parameters has always had an answer (`params.permit(metadata: {})`); now so does a contract. `optional :metadata, :json` passes an arbitrary Hash through untouched — keys neither filtered nor cast, nested arrays and mixed scalars intact, `unknown:` deliberately not descending into it, `{}` a value rather than an absence, anything that is not a Hash an `invalid_type`. What it gives up is the shape; what it keeps is every bound worth having: **`length:`** caps the top-level key count, **`max_depth:`** caps container nesting with arrays counting as a level (violation code `depth`), `validate:`/`transform:` see the whole hash, and the field maps onto a column like a scalar does, so the schema-drift guard still catches a dropped `metadata` column. That matters more than it looks — an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects, and "opaque, but not unlimited" is strictly more than `permit(metadata: {})` can say. Values arrive as plain data, never `ActionController::Parameters`, so assigning straight to a `jsonb` attribute is safe. Exported as `{"type": "object"}` with `minProperties`/`maxProperties`, plus `x-permittable-max-depth` for the nesting bound JSON Schema has no keyword for.
|
|
10
|
+
- **`permittable:generate` now drafts `json`, `jsonb` and `hstore` columns as `:json`** instead of leaving a TODO comment. Columns with no faithful representation at all (`binary`, geometry types) still become TODOs rather than guesses.
|
|
11
|
+
|
|
12
|
+
- **`nullable:` field option — an explicit null is now part of a contract's vocabulary.** One absence rule (`nil` and `""` are both absent) is right for `PATCH` and wrong for the request that means *clear this*; `nullable: true` splits it in two for a single field. A key the client never sent stays **absent** — `default:` applies to it, a `required` field still violates `missing` — but a key sent **empty** (JSON `null`, or `""` from a form) is an explicit null and yields `nil` in the result, **ahead of the field's `default:`**, which is exactly what a `PATCH` clearing a column needs. Nothing is cast or checked for an explicit null: `in:`, `format:`, `length:`, `validate:`, and `transform:` never see a `nil` they didn't agree to handle. `required` + `nullable` reads as it does in SQL (the client must state the field; `null` is a legal statement), `default: nil` — legal only on a nullable field — gives the `PUT` reading where absence also means clear, and on arrays and nested blocks `nullable:` applies to the array or object itself, never its contents (a null *element* is still `invalid_type`). Exported JSON Schema / OpenAPI stays truthful: the field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum` (the one keyword that constrains the instance rather than a type). The RSpec matcher gains a `.nullable` chain, and `default: nil` / `example: nil` on a non-nullable field now fails at class load naming the fix, instead of the confusing `invalid_type`.
|
|
13
|
+
|
|
14
|
+
Contracts that don't opt in are byte-for-byte unaffected: absence keeps its single meaning and nothing new appears in an exported schema.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **`:date` and `:datetime` invented the parts a string left out, from today's date.** Coercion is documented as strict — "a value the type cannot faithfully represent is a violation, not a guess" — but it handed strings straight to `Date.parse`, which fills in what they omit from the current date: `"09/2026"` became the 1st of September, `"5th"` became the 5th of *this* month of *this* year, `"Sept"` became the 1st of September *this* year. The same request therefore meant different things on different days, which is a guess and a non-deterministic one. A `:date` or `:datetime` string must now name all three of year, month and day; which **format** it names them in is still `Date.parse`'s business, so every complete format it understands keeps working (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`, `"5 September 2026"`). A `:datetime` may still omit the **time** part, which reads as midnight UTC as documented, but a string with only a time (`"10:30"`, previously *today* at 10:30) is now `invalid_type`. `Date`, `Time`, `DateTime` and `ActiveSupport::TimeWithZone` objects are unaffected.
|
|
18
|
+
|
|
19
|
+
This is a **behaviour change** for any endpoint that was relying on the fill-in, but the values it produced were not the ones the client meant, and an exported `"format": "date"` already promised RFC 3339 rather than `"5th"`.
|
|
20
|
+
|
|
21
|
+
## 0.5.2 (2026-09-06)
|
|
22
|
+
<!-- title: Railtie coverage and a new README -->
|
|
23
|
+
|
|
24
|
+
Patch release with no behaviour changes: the boot-time integration gets its first real spec, and the README was rewritten from the ground up.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **`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.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- **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.
|
|
31
|
+
|
|
3
32
|
## 0.5.1 (2026-09-02)
|
|
4
33
|
<!-- title: nested input outside Rails -->
|
|
5
34
|
|
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 | ❌ | ❌ | ✅ |
|
|
78
102
|
|
|
79
|
-
|
|
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).
|
|
80
104
|
|
|
81
|
-
|
|
105
|
+
## One idea: a contract is data
|
|
82
106
|
|
|
83
|
-
|
|
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:
|
|
108
|
+
|
|
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:
|
|
128
|
+
|
|
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.
|
|
135
|
+
|
|
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,79 @@ 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
|
+
- [Free-form hashes](#free-form-hashes-json)
|
|
183
|
+
- [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
|
|
184
|
+
- [Explicit nulls](#explicit-nulls-nullable)
|
|
185
|
+
- [Violations and error responses](#violations-and-error-responses)
|
|
186
|
+
- [Custom error messages](#custom-error-messages-message) · [Localizing with I18n](#localizing-default-messages-i18n)
|
|
187
|
+
- [Unknown parameters](#unknown-parameters)
|
|
188
|
+
- [Output reshaping](#output-reshaping-transform-and-finalize)
|
|
189
|
+
- [The schema-drift guard](#the-schema-drift-guard)
|
|
190
|
+
- [Sensitive parameters and log redaction](#sensitive-parameters-and-log-redaction)
|
|
191
|
+
- [Instrumentation](#instrumentation)
|
|
192
|
+
- **[Adopting on a live API](#adopting-on-a-live-api)**
|
|
193
|
+
- [Monitor mode](#monitor-mode-roll-out-without-rejecting)
|
|
194
|
+
- [Generating draft contracts](#generating-draft-contracts-permittablegenerate)
|
|
195
|
+
- **[Beyond the controller](#beyond-the-controller)**
|
|
196
|
+
- [Testing contracts](#testing-contracts-rspec-matchers)
|
|
197
|
+
- [Standalone contracts](#standalone-contracts-no-controller)
|
|
198
|
+
- [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
|
|
199
|
+
- **[Reference](#reference)**
|
|
200
|
+
- [API](#api) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
|
|
201
|
+
- [Development](#development) · [License](#license)
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Guide
|
|
206
|
+
|
|
207
|
+
### How a request flows
|
|
102
208
|
|
|
103
209
|
```
|
|
104
|
-
params
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
210
|
+
request params
|
|
211
|
+
│
|
|
212
|
+
├─ 1 unwrap root: params[:user] missing or not a hash → 400
|
|
213
|
+
├─ 2 each field normalize → cast → validate → transform
|
|
214
|
+
├─ 3 unknown-key check at every nesting level (unknown: :ignore | :log | :error)
|
|
215
|
+
├─ 4 finalize only when nothing violated
|
|
216
|
+
│
|
|
217
|
+
└─ permitted_params → HashWithIndifferentAccess or raises InvalidParameters → 422
|
|
112
218
|
```
|
|
113
219
|
|
|
114
220
|
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
221
|
|
|
116
222
|
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
223
|
|
|
118
|
-
|
|
224
|
+
### Declaring a contract
|
|
119
225
|
|
|
120
226
|
```ruby
|
|
121
227
|
permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &contract)
|
|
@@ -147,39 +253,35 @@ end
|
|
|
147
253
|
|
|
148
254
|
Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
|
|
149
255
|
|
|
150
|
-
|
|
256
|
+
### The field DSL
|
|
151
257
|
|
|
152
|
-
|
|
258
|
+
Three verbs. `required` and `optional` declare scalars (or, with a block, nested hashes); `array` declares a list.
|
|
153
259
|
|
|
154
260
|
```ruby
|
|
155
|
-
|
|
261
|
+
# Scalars — the type defaults to :string
|
|
262
|
+
required :name, :string
|
|
156
263
|
optional :age, :integer
|
|
157
|
-
```
|
|
158
264
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
Pass a block instead of a type. Violation paths are dotted (`user.address.zip`).
|
|
162
|
-
|
|
163
|
-
```ruby
|
|
265
|
+
# Nested hashes — pass a block instead of a type. Violation paths are dotted: user.address.zip
|
|
164
266
|
optional :address do
|
|
165
267
|
required :city, :string
|
|
166
268
|
optional :zip, :string, format: /\A\d{5}\z/
|
|
167
269
|
end
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### Arrays
|
|
171
270
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
```ruby
|
|
175
|
-
array :tag_names, of: :string, length: 0..10
|
|
271
|
+
# Arrays — of: for scalars, a block for hashes. Element failures carry their index: items[1].sku
|
|
272
|
+
array :tag_names, of: :string, length: 0..10
|
|
176
273
|
array :line_items, required: true do
|
|
177
274
|
required :sku, :string
|
|
178
275
|
required :quantity, :integer, in: 1..99
|
|
179
276
|
end
|
|
277
|
+
|
|
278
|
+
# Free-form hashes — :json takes any hash, uncast and unfiltered, with bounds
|
|
279
|
+
optional :metadata, :json, max_depth: 3, length: 0..32
|
|
180
280
|
```
|
|
181
281
|
|
|
182
|
-
|
|
282
|
+
Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
|
|
283
|
+
|
|
284
|
+
### Field options
|
|
183
285
|
|
|
184
286
|
Which options are legal depends on the field kind — anything else raises at class load.
|
|
185
287
|
|
|
@@ -196,9 +298,11 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
196
298
|
| `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
|
|
197
299
|
| `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
|
|
198
300
|
| `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
|
|
301
|
+
| `max_depth:` | — | — | — | `:json` fields only — maximum container nesting. See [free-form hashes](#free-form-hashes-json) |
|
|
199
302
|
| `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
|
|
200
303
|
| `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
|
|
201
304
|
| `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
|
|
305
|
+
| `nullable:` | ✅ | ✅ | ✅ | An explicitly-sent empty value yields `nil` instead of counting as absent — see [explicit nulls](#explicit-nulls-nullable) |
|
|
202
306
|
|
|
203
307
|
¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
|
|
204
308
|
|
|
@@ -208,7 +312,7 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
208
312
|
optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
|
|
209
313
|
```
|
|
210
314
|
|
|
211
|
-
|
|
315
|
+
### Types and strict coercion
|
|
212
316
|
|
|
213
317
|
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
318
|
|
|
@@ -219,31 +323,94 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
219
323
|
| `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
|
|
220
324
|
| `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
|
|
221
325
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
222
|
-
| `:date` | `Date`; any `Date.parse
|
|
223
|
-
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date
|
|
326
|
+
| `:date` | `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, and **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`) |
|
|
327
|
+
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time | Unparseable strings, and any string without a complete date (`"10:30"`) |
|
|
328
|
+
| `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
|
|
329
|
+
|
|
330
|
+
**Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC.
|
|
224
331
|
|
|
225
|
-
Two behaviours worth committing to memory:
|
|
332
|
+
Two more behaviours worth committing to memory:
|
|
226
333
|
|
|
227
334
|
- **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
335
|
- **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
336
|
|
|
230
|
-
|
|
337
|
+
### Free-form hashes (`:json`)
|
|
338
|
+
|
|
339
|
+
A `json`/`jsonb` column exists precisely so its contents need no schema. Every other field kind describes a shape, so until `:json` a contract had only bad options for one: declare sub-keys you don't know, or leave the key undeclared — in which case the contract **silently dropped it**, and the column never saw the data. Strong parameters has always had an answer here (`params.permit(metadata: {})`); now so does a contract.
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
permit_params :create, root: :user, model: User do
|
|
343
|
+
required :name, :string
|
|
344
|
+
optional :metadata, :json, max_depth: 3, length: 0..32
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The hash passes through **untouched** — keys are neither filtered nor cast, nested arrays and mixed scalars survive, and `unknown:` does not descend into it. `{}` is a value, not an absence. Anything that is not a hash (an array, a string, a number) is `invalid_type`.
|
|
349
|
+
|
|
350
|
+
What you give up is the shape. What you keep:
|
|
351
|
+
|
|
352
|
+
| | |
|
|
353
|
+
|---|---|
|
|
354
|
+
| `length:` | Caps the **top-level key count** — same reading as an array's element count |
|
|
355
|
+
| `max_depth:` | Caps **container nesting**, counting arrays as a level: `{"a": 1}` is 1, `{"a": {"b": 1}}` and `{"a": [1, 2]}` are 2, `{"a": [{"b": 1}]}` is 3. Violation code `depth` |
|
|
356
|
+
| `validate:` / `transform:` | See the whole hash, so any check you can write in Ruby still applies |
|
|
357
|
+
| `model:` | The field maps onto a column like a scalar does, so the [drift guard](#the-schema-drift-guard) still catches a dropped `metadata` column |
|
|
358
|
+
| `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash, and are checked against the field's own bounds at class load) |
|
|
359
|
+
|
|
360
|
+
Bounding it matters more than it looks: an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects. `max_depth:` and `length:` are how a contract says "opaque, but not unlimited" — which is strictly more than `permit(metadata: {})` can say.
|
|
361
|
+
|
|
362
|
+
Values arrive as plain data (`HashWithIndifferentAccess`), never `ActionController::Parameters`, so assigning straight to a `jsonb` attribute is safe.
|
|
363
|
+
|
|
364
|
+
In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{"type": "object"}` plus `minProperties`/`maxProperties`; JSON Schema has no nesting-depth keyword, so `max_depth:` stays visible as `x-permittable-max-depth` rather than being dropped or mistranslated.
|
|
365
|
+
|
|
366
|
+
### Absence, defaults, and partial updates
|
|
231
367
|
|
|
232
368
|
`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
369
|
|
|
234
370
|
That single rule produces the behaviour you want from a `PATCH`:
|
|
235
371
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
372
|
+
| The field is… | Result |
|
|
373
|
+
|---|---|
|
|
374
|
+
| absent and **optional** | omitted from the result, so partial updates never nil-out columns |
|
|
375
|
+
| absent and **required** | a `missing` violation |
|
|
376
|
+
| absent with a **`default:`** | the default — a defaulted field can never report `missing` |
|
|
239
377
|
|
|
240
|
-
|
|
378
|
+
Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality. And because absence and `nil` are the same thing here, a plain field cannot clear a column to NULL — declare it [`nullable:`](#explicit-nulls-nullable) when it should.
|
|
241
379
|
|
|
242
380
|
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
381
|
|
|
244
|
-
|
|
382
|
+
### Explicit nulls (`nullable:`)
|
|
245
383
|
|
|
246
|
-
|
|
384
|
+
One rule — `nil` and `""` are absent — is right for `PATCH` and wrong for the request that means *clear this*. `nullable: true` splits it in two for a single field:
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
permit_params :update, root: :user, model: User do
|
|
388
|
+
optional :nickname, :string, nullable: true
|
|
389
|
+
optional :plan, :string, in: %w[free pro], default: "free", nullable: true
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
| Request | `nickname` in the result |
|
|
394
|
+
|---|---|
|
|
395
|
+
| `{ "user": {} }` | **omitted** — the column is untouched |
|
|
396
|
+
| `{ "user": { "nickname": null } }` | `nil` — the column is cleared |
|
|
397
|
+
| `{ "user": { "nickname": "" } }` | `nil` — the form-encoded spelling of the same intent |
|
|
398
|
+
|
|
399
|
+
A key the client never sent is still **absent**: `default:` applies to it and a `required` field still violates with `missing`. Only *present-but-empty* changes meaning, and it changes it decisively — an explicit null wins over the field's `default:`, which is the behaviour a `PATCH` needs (`{ "plan": null }` clears the plan instead of silently resetting it to `"free"`).
|
|
400
|
+
|
|
401
|
+
Nothing is cast or checked for an explicit null. `in:`, `format:`, `length:`, `validate:`, and `transform:` all see a value or nothing at all — never a `nil` they never agreed to handle.
|
|
402
|
+
|
|
403
|
+
Three more readings worth knowing:
|
|
404
|
+
|
|
405
|
+
- **`required` + `nullable`** is coherent, and means what it says in SQL: the client *must* state the field, and `null` is a legal statement. A missing key still violates.
|
|
406
|
+
- **`default: nil`** — legal only on a nullable field — gives the `PUT` reading, where absence *also* means clear.
|
|
407
|
+
- **On arrays and nested blocks**, `nullable:` applies to the array or object itself, never to its contents. `{ "tags": null }` yields `nil` (distinct from `[]`, which still gets length-checked); a null *element* inside `tags` is still `invalid_type`.
|
|
408
|
+
|
|
409
|
+
Exported [OpenAPI](#exporting-openapi-docs-that-cannot-drift) tells the truth about all of this: a nullable field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum`.
|
|
410
|
+
|
|
411
|
+
### Violations and error responses
|
|
412
|
+
|
|
413
|
+
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
414
|
|
|
248
415
|
| Code | Raised when |
|
|
249
416
|
|---|---|
|
|
@@ -258,11 +425,11 @@ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an ar
|
|
|
258
425
|
|
|
259
426
|
Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
|
|
260
427
|
|
|
261
|
-
**Status codes
|
|
428
|
+
**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
429
|
|
|
263
|
-
**Custom rendering
|
|
430
|
+
**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
431
|
|
|
265
|
-
|
|
432
|
+
### Custom error messages (`message:`)
|
|
266
433
|
|
|
267
434
|
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
435
|
|
|
@@ -293,7 +460,7 @@ The rules:
|
|
|
293
460
|
- `violate!` in `finalize` takes the same idea as a keyword: `violate!("user.ends_at", :before_start, message: "must be after starts_at")`.
|
|
294
461
|
- A `message:` that is neither a String nor a code → String Hash raises at class load, like every other contract mistake.
|
|
295
462
|
|
|
296
|
-
|
|
463
|
+
#### Localizing default messages (I18n)
|
|
297
464
|
|
|
298
465
|
App-wide copy for a violation code — without repeating `message:` on every field — comes from I18n, under `permittable.errors.<code>`:
|
|
299
466
|
|
|
@@ -310,9 +477,7 @@ en:
|
|
|
310
477
|
|
|
311
478
|
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
479
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
## Unknown parameters
|
|
480
|
+
### Unknown parameters
|
|
316
481
|
|
|
317
482
|
`unknown:` decides what happens to keys you never declared, **at every nesting level**.
|
|
318
483
|
|
|
@@ -324,13 +489,11 @@ For full control over the response body itself (RFC 9457, a different envelope),
|
|
|
324
489
|
|
|
325
490
|
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
491
|
|
|
327
|
-
|
|
492
|
+
### Output reshaping (`transform:` and `finalize`)
|
|
328
493
|
|
|
329
494
|
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
495
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
A callable applied **after** cast and validation, reshaping one field's output:
|
|
496
|
+
**`transform:` — per field.** A callable applied **after** cast and validation, reshaping one field's output:
|
|
334
497
|
|
|
335
498
|
```ruby
|
|
336
499
|
required :tags, :string, transform: ->(v) { v.split(",") }
|
|
@@ -338,11 +501,9 @@ required :tags, :string, transform: ->(v) { v.split(",") }
|
|
|
338
501
|
|
|
339
502
|
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
503
|
|
|
341
|
-
|
|
504
|
+
**`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
505
|
|
|
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).
|
|
506
|
+
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
507
|
|
|
347
508
|
```ruby
|
|
348
509
|
permit_params :create, root: :lease_addendum_form do
|
|
@@ -364,7 +525,7 @@ end
|
|
|
364
525
|
|
|
365
526
|
Forgetting to return the hash raises an `ArgumentError` telling you exactly that.
|
|
366
527
|
|
|
367
|
-
|
|
528
|
+
### The schema-drift guard
|
|
368
529
|
|
|
369
530
|
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
531
|
|
|
@@ -384,7 +545,7 @@ The error carries a ready-to-paste migration command, typed from your own field
|
|
|
384
545
|
|
|
385
546
|
In CI, one spec calling `Rails.application.eager_load!` exercises every contract in the whole app.
|
|
386
547
|
|
|
387
|
-
|
|
548
|
+
### Sensitive parameters and log redaction
|
|
388
549
|
|
|
389
550
|
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
551
|
|
|
@@ -396,7 +557,7 @@ The indirection is deliberate. Appending plain symbols to `config.filter_paramet
|
|
|
396
557
|
|
|
397
558
|
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
559
|
|
|
399
|
-
|
|
560
|
+
### Instrumentation
|
|
400
561
|
|
|
401
562
|
Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
|
|
402
563
|
|
|
@@ -404,13 +565,29 @@ Every violation emits an `ActiveSupport::Notifications` event, so rejected reque
|
|
|
404
565
|
ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
|
|
405
566
|
payload[:controller] # "users"
|
|
406
567
|
payload[:action] # "create"
|
|
568
|
+
payload[:mode] # :enforce, or :monitor for a would-be rejection
|
|
407
569
|
payload[:details] # [{ param: "user.age", code: "inclusion" }]
|
|
408
570
|
end
|
|
409
571
|
```
|
|
410
572
|
|
|
411
|
-
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## Adopting on a live API
|
|
412
576
|
|
|
413
|
-
|
|
577
|
+
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.
|
|
578
|
+
|
|
579
|
+
Permittable's answer is an afternoon-sized recipe:
|
|
580
|
+
|
|
581
|
+
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.
|
|
582
|
+
2. **Monitor.** Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; every would-be rejection is logged and instrumented.
|
|
583
|
+
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.
|
|
584
|
+
4. **Enforce.** Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
|
|
585
|
+
|
|
586
|
+
The two halves of that recipe are below.
|
|
587
|
+
|
|
588
|
+
### Monitor mode (roll out without rejecting)
|
|
589
|
+
|
|
590
|
+
`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
591
|
|
|
415
592
|
```ruby
|
|
416
593
|
class OrdersController < ApplicationController
|
|
@@ -434,24 +611,17 @@ Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym
|
|
|
434
611
|
On a violating request in monitor mode:
|
|
435
612
|
|
|
436
613
|
- **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
|
|
614
|
+
- 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.
|
|
615
|
+
- `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
616
|
- `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
|
|
440
617
|
|
|
441
618
|
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
619
|
|
|
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
620
|
[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
621
|
|
|
452
|
-
|
|
622
|
+
### Generating draft contracts (`permittable:generate`)
|
|
453
623
|
|
|
454
|
-
The blank-page problem, solved: the first draft of every contract
|
|
624
|
+
The blank-page problem, solved: the first draft of every contract is generated from what the app already knows.
|
|
455
625
|
|
|
456
626
|
```sh
|
|
457
627
|
bin/rails permittable:generate # every controller without a contract
|
|
@@ -481,11 +651,15 @@ The generator's one rule is **draft, don't guess** — everything it cannot know
|
|
|
481
651
|
|
|
482
652
|
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
653
|
|
|
484
|
-
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
## Beyond the controller
|
|
485
657
|
|
|
486
|
-
|
|
658
|
+
Because a contract is data, it has readers other than the request validator.
|
|
487
659
|
|
|
488
|
-
|
|
660
|
+
### Testing contracts (RSpec matchers)
|
|
661
|
+
|
|
662
|
+
A contract can be specified without dispatching a request. `require "permittable/rspec"` (in `spec_helper.rb`) auto-includes the matchers:
|
|
489
663
|
|
|
490
664
|
```ruby
|
|
491
665
|
RSpec.describe UsersController do
|
|
@@ -505,7 +679,7 @@ Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (
|
|
|
505
679
|
|
|
506
680
|
`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
681
|
|
|
508
|
-
|
|
682
|
+
### Standalone contracts (no controller)
|
|
509
683
|
|
|
510
684
|
The same DSL, callable on any Hash — webhook payloads, job arguments, service-object inputs, CSV rows:
|
|
511
685
|
|
|
@@ -532,9 +706,9 @@ Everything carries over — strict coercion, `""`/`nil` absence, defaults, `fina
|
|
|
532
706
|
- **No router-key exemption.** `unknown: :error` flags a stray `action` or `controller` key — standalone input has no router to excuse.
|
|
533
707
|
- **No memoization.** Every `#call` validates fresh, so one frozen contract is safely reusable and shareable (assign it to a constant).
|
|
534
708
|
|
|
535
|
-
|
|
709
|
+
### Exporting OpenAPI (docs that cannot drift)
|
|
536
710
|
|
|
537
|
-
|
|
711
|
+
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
712
|
|
|
539
713
|
```sh
|
|
540
714
|
bin/rails permittable:openapi # JSON to stdout
|
|
@@ -552,7 +726,16 @@ Permittable::OpenAPI.operations_for(UsersController) # { acti
|
|
|
552
726
|
Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
553
727
|
```
|
|
554
728
|
|
|
555
|
-
|
|
729
|
+
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.
|
|
730
|
+
|
|
731
|
+
**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.
|
|
732
|
+
|
|
733
|
+
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.
|
|
734
|
+
|
|
735
|
+
<details>
|
|
736
|
+
<summary><strong>How contracts map onto JSON Schema</strong></summary>
|
|
737
|
+
|
|
738
|
+
<br>
|
|
556
739
|
|
|
557
740
|
| Contract | Emitted schema |
|
|
558
741
|
|---|---|
|
|
@@ -569,15 +752,15 @@ How contracts map:
|
|
|
569
752
|
| `root:` | the wrapping object, itself required |
|
|
570
753
|
| `sensitive: true` | `writeOnly: true` (never echoed in responses) |
|
|
571
754
|
|
|
572
|
-
|
|
755
|
+
</details>
|
|
573
756
|
|
|
574
|
-
|
|
757
|
+
---
|
|
575
758
|
|
|
576
|
-
|
|
759
|
+
## Reference
|
|
577
760
|
|
|
578
|
-
|
|
761
|
+
### API
|
|
579
762
|
|
|
580
|
-
|
|
763
|
+
**Instance methods**
|
|
581
764
|
|
|
582
765
|
| Method | Purpose |
|
|
583
766
|
|---|---|
|
|
@@ -586,7 +769,7 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
586
769
|
| `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
770
|
| `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
|
|
588
771
|
|
|
589
|
-
|
|
772
|
+
**Class methods**
|
|
590
773
|
|
|
591
774
|
| Method | Purpose |
|
|
592
775
|
|---|---|
|
|
@@ -594,9 +777,9 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
594
777
|
| `permittable_contracts` | The frozen array of every declared rule — introspectable, testable |
|
|
595
778
|
| `permit_rule_for(action)` | The last rule matching `action`, or `nil` |
|
|
596
779
|
|
|
597
|
-
|
|
780
|
+
**Module**
|
|
598
781
|
|
|
599
|
-
| | |
|
|
782
|
+
| Constant | Purpose |
|
|
600
783
|
|---|---|
|
|
601
784
|
| `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
|
|
602
785
|
| `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
|
|
@@ -608,10 +791,15 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
608
791
|
| `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`) |
|
|
609
792
|
| `Permittable::Matchers` | RSpec matchers via `require "permittable/rspec"` — see [testing contracts](#testing-contracts-rspec-matchers) |
|
|
610
793
|
|
|
611
|
-
|
|
794
|
+
### Errors caught at class load
|
|
612
795
|
|
|
613
796
|
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
797
|
|
|
798
|
+
<details>
|
|
799
|
+
<summary><strong>The full list</strong></summary>
|
|
800
|
+
|
|
801
|
+
<br>
|
|
802
|
+
|
|
615
803
|
- A field declared twice in one contract
|
|
616
804
|
- An unknown option for the field's kind, listing what *is* allowed
|
|
617
805
|
- An unknown type, listing the supported ones
|
|
@@ -620,31 +808,40 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
620
808
|
- `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
|
|
621
809
|
- `validate:` or `transform:` that isn't callable
|
|
622
810
|
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
|
|
811
|
+
- A `default: nil` or `example: nil` on a field that isn't `nullable:`
|
|
812
|
+
- A `:json` field's `default:`/`example:` that isn't a Hash, or that its own `length:`/`max_depth:` would reject
|
|
813
|
+
- A `max_depth:` that isn't a positive Integer
|
|
623
814
|
- `required: true` combined with `default:`
|
|
624
815
|
- A field given both a type and a nested block; an array given both `of:` and a block
|
|
625
816
|
- An empty contract, or a nested block declaring no sub-fields
|
|
626
817
|
- `finalize` declared twice, without a block, or inside a nested block
|
|
627
818
|
- `permit_params` without a block, or an invalid `unknown:` mode
|
|
819
|
+
- A `root:` that isn't a single key (several top-level envelopes are a rootless contract with one nested block per key)
|
|
628
820
|
- An invalid `mode:` (and `Permittable.mode =` rejects invalid values at assignment)
|
|
629
821
|
- A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
|
|
630
822
|
|
|
631
|
-
|
|
823
|
+
</details>
|
|
632
824
|
|
|
633
|
-
|
|
825
|
+
### Compatibility
|
|
826
|
+
|
|
827
|
+
| Requirement | Supported |
|
|
634
828
|
|---|---|
|
|
635
829
|
| Ruby | >= 3.2 |
|
|
636
830
|
| Rails / ActiveSupport | >= 5.0, < 9 |
|
|
637
831
|
| Required dependency | `activesupport` only |
|
|
638
832
|
| Optional | `actionpack` (rendering, `before_action`), `activerecord` (drift guard) |
|
|
639
833
|
|
|
834
|
+
`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.
|
|
835
|
+
|
|
640
836
|
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
837
|
|
|
642
838
|
## Development
|
|
643
839
|
|
|
644
840
|
```sh
|
|
645
841
|
bundle install
|
|
646
|
-
bundle exec rspec
|
|
842
|
+
bundle exec rspec # the suite, with a coverage report in coverage/
|
|
647
843
|
bundle exec rubocop
|
|
844
|
+
bundle exec ruby benchmark/overhead.rb # a full contract vs. the params.permit call it replaces
|
|
648
845
|
```
|
|
649
846
|
|
|
650
847
|
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.
|
|
@@ -19,14 +19,17 @@ module Permittable
|
|
|
19
19
|
DEFAULT_ACTIONS = %i[create update].freeze
|
|
20
20
|
SKIPPED_COLUMNS = %w[created_at updated_at].freeze
|
|
21
21
|
|
|
22
|
-
# Column type => contract type.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# Column type => contract type. Document-shaped columns map onto the
|
|
23
|
+
# opaque `:json` field — the shape stays undeclared, which is what a
|
|
24
|
+
# jsonb column is for, and `max_depth:`/`length:` can bound it later.
|
|
25
|
+
# Anything absent here (binary, geometry, ...) has no faithful
|
|
26
|
+
# representation and becomes a TODO comment rather than a guess.
|
|
25
27
|
COLUMN_TYPES = {
|
|
26
28
|
string: :string, text: :string, citext: :string, uuid: :string,
|
|
27
29
|
integer: :integer, bigint: :integer, float: :float, decimal: :decimal,
|
|
28
30
|
boolean: :boolean, date: :date, datetime: :datetime,
|
|
29
|
-
timestamp: :datetime, timestamptz: :datetime
|
|
31
|
+
timestamp: :datetime, timestamptz: :datetime,
|
|
32
|
+
json: :json, jsonb: :json, hstore: :json
|
|
30
33
|
}.freeze
|
|
31
34
|
|
|
32
35
|
# What a source scan recovered from existing permit calls. `scalars` are
|
|
@@ -168,7 +171,7 @@ module Permittable
|
|
|
168
171
|
|
|
169
172
|
def column_line(column)
|
|
170
173
|
type = COLUMN_TYPES[column.type]
|
|
171
|
-
return "# TODO: #{column.name} (#{column.type}) has no
|
|
174
|
+
return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
|
|
172
175
|
|
|
173
176
|
line = "#{required_column?(column) ? 'required' : 'optional'} :#{column.name}, :#{type}"
|
|
174
177
|
line += " # database default: #{column.default.inspect}" unless column.default.nil?
|
|
@@ -75,12 +75,29 @@ module Permittable
|
|
|
75
75
|
def field(field, unknown: :ignore)
|
|
76
76
|
schema = case field[:kind]
|
|
77
77
|
when :scalar then scalar_schema(field)
|
|
78
|
+
when :json then opaque_schema(field)
|
|
78
79
|
when :nested then object(field[:fields], unknown: unknown)
|
|
79
80
|
when :array then array_schema(field, unknown: unknown)
|
|
80
81
|
end
|
|
82
|
+
nullify!(schema, field)
|
|
81
83
|
annotate(schema, field)
|
|
82
84
|
end
|
|
83
85
|
|
|
86
|
+
# `nullable: true` means an explicitly-sent empty value yields null, so
|
|
87
|
+
# the type gains "null". Assigning over the existing key keeps its
|
|
88
|
+
# position, preserving deterministic emission. `enum` is the one keyword
|
|
89
|
+
# that constrains the instance rather than one type (minLength, pattern,
|
|
90
|
+
# minimum and friends only apply to instances of their own type), so a
|
|
91
|
+
# nullable enum has to list null itself or it would reject the very null
|
|
92
|
+
# the type now permits.
|
|
93
|
+
def nullify!(schema, field)
|
|
94
|
+
return schema unless field[:nullable]
|
|
95
|
+
|
|
96
|
+
schema["type"] = Array(schema["type"]) + ["null"] if schema["type"]
|
|
97
|
+
schema["enum"] += [nil] if schema.key?("enum")
|
|
98
|
+
schema
|
|
99
|
+
end
|
|
100
|
+
|
|
84
101
|
def scalar_schema(field)
|
|
85
102
|
schema = SCALAR_SCHEMAS.fetch(field[:type]).dup
|
|
86
103
|
apply_in!(schema, field[:in])
|
|
@@ -89,6 +106,19 @@ module Permittable
|
|
|
89
106
|
schema
|
|
90
107
|
end
|
|
91
108
|
|
|
109
|
+
# A `:json` field's shape is deliberately undeclared, so the schema says
|
|
110
|
+
# "an object" and carries only the bounds the field does declare. JSON
|
|
111
|
+
# Schema has no nesting-depth keyword, so `max_depth:` stays visible as an
|
|
112
|
+
# extension rather than being dropped or mistranslated.
|
|
113
|
+
def opaque_schema(field)
|
|
114
|
+
schema = { "type" => "object" }
|
|
115
|
+
min, max = length_bounds(field[:length])
|
|
116
|
+
schema["minProperties"] = min if min
|
|
117
|
+
schema["maxProperties"] = max if max
|
|
118
|
+
schema["x-permittable-max-depth"] = field[:max_depth] if field[:max_depth]
|
|
119
|
+
schema
|
|
120
|
+
end
|
|
121
|
+
|
|
92
122
|
def array_schema(field, unknown:)
|
|
93
123
|
schema = { "type" => "array" }
|
|
94
124
|
min, max = length_bounds(field[:length])
|
|
@@ -183,6 +213,9 @@ module Permittable
|
|
|
183
213
|
def json_value(value)
|
|
184
214
|
case value
|
|
185
215
|
when Array then value.map { |v| json_value(v) }
|
|
216
|
+
# An authored `:json` default/example is a whole hash; its values get
|
|
217
|
+
# the same re-encoding as any other authored scalar.
|
|
218
|
+
when Hash then value.to_h { |k, v| [k.to_s, json_value(v)] }
|
|
186
219
|
when BigDecimal then value.to_s("F")
|
|
187
220
|
when Time then value.utc.iso8601
|
|
188
221
|
# DateTime subclasses Date, so it must match first.
|
data/lib/permittable/rspec.rb
CHANGED
|
@@ -91,6 +91,11 @@ module Permittable
|
|
|
91
91
|
self
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
def nullable
|
|
95
|
+
@expected[:nullable] = true
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
94
99
|
# -- RSpec protocol ---------------------------------------------------
|
|
95
100
|
|
|
96
101
|
def matches?(subject)
|
|
@@ -191,7 +196,7 @@ module Permittable
|
|
|
191
196
|
when :array then "expected an array field, but it is declared with `#{field[:kind]}`" unless field[:kind] == :array
|
|
192
197
|
when :of then "expected an array of :#{value}, but it is of: :#{field[:of]}" unless field[:of] == value
|
|
193
198
|
when :required then required_mismatch(field, value)
|
|
194
|
-
when :virtual, :sensitive then "expected the field to be #{key}, but it is not" unless field[key]
|
|
199
|
+
when :virtual, :sensitive, :nullable then "expected the field to be #{key}, but it is not" unless field[key]
|
|
195
200
|
else option_mismatch(field, key, value)
|
|
196
201
|
end
|
|
197
202
|
end
|
|
@@ -226,7 +231,7 @@ module Permittable
|
|
|
226
231
|
when :array then "as an array"
|
|
227
232
|
when :of then "of :#{value}"
|
|
228
233
|
when :required then value ? "required" : "optional"
|
|
229
|
-
when :virtual, :sensitive then key.to_s
|
|
234
|
+
when :virtual, :sensitive, :nullable then key.to_s
|
|
230
235
|
else "#{OPTION_LABELS.fetch(key)} #{value.inspect}"
|
|
231
236
|
end
|
|
232
237
|
end
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -34,6 +34,7 @@ require "permittable/filter_parameter_registry"
|
|
|
34
34
|
# optional :ssn, :string, sensitive: true
|
|
35
35
|
# optional :plan, :string, in: %w[free pro], default: "free"
|
|
36
36
|
# array :tag_names, of: :string, length: 0..10, virtual: true
|
|
37
|
+
# optional :metadata, :json, max_depth: 3, length: 0..32
|
|
37
38
|
# optional :address do
|
|
38
39
|
# required :city, :string
|
|
39
40
|
# optional :zip, :string, format: /\A\d{5}\z/
|
|
@@ -79,6 +80,17 @@ require "permittable/filter_parameter_registry"
|
|
|
79
80
|
# request. `permittable_violations` reads the recorded details ([] when
|
|
80
81
|
# the request was clean).
|
|
81
82
|
#
|
|
83
|
+
# THE :json FIELD — the deliberate hole. A json/jsonb column exists precisely
|
|
84
|
+
# so its contents need no schema, and until it was declarable a contract could
|
|
85
|
+
# only drop that key (strong parameters spells it `permit(metadata: {})`).
|
|
86
|
+
# `optional :metadata, :json` passes an arbitrary Hash through untouched —
|
|
87
|
+
# keys are neither filtered nor cast, and `unknown:` does not descend into it
|
|
88
|
+
# — while still letting the contract bound the shape it refuses to describe:
|
|
89
|
+
# `length:` caps the top-level key count, `max_depth:` caps container nesting
|
|
90
|
+
# (arrays count as a level), and `validate:`/`transform:` see the whole hash.
|
|
91
|
+
# Anything that is not a Hash is `invalid_type`, and the field still maps onto
|
|
92
|
+
# a column for the drift guard.
|
|
93
|
+
#
|
|
82
94
|
# Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
|
|
83
95
|
# casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
|
|
84
96
|
# and silently corrupting untrusted input is exactly what a contract must not
|
|
@@ -86,8 +98,16 @@ require "permittable/filter_parameter_registry"
|
|
|
86
98
|
# guess. nil and "" are both treated as ABSENT (the query-param convention):
|
|
87
99
|
# absent optional fields are OMITTED from the result (so partial updates never
|
|
88
100
|
# nil-out columns), absent required fields violate, and `default:` fills
|
|
89
|
-
# absence.
|
|
90
|
-
#
|
|
101
|
+
# absence.
|
|
102
|
+
#
|
|
103
|
+
# `nullable: true` splits that rule in two for one field, which is how a PATCH
|
|
104
|
+
# clears a column: a key the client never sent stays absent (defaults apply,
|
|
105
|
+
# required violates), but a key sent EMPTY (JSON null, or "" from a form) is an
|
|
106
|
+
# explicit null and yields nil in the result — ahead of any `default:`, and
|
|
107
|
+
# without casting or checking a value that isn't there. It reads on arrays and
|
|
108
|
+
# nested blocks too (the array/object itself may be null, never its elements),
|
|
109
|
+
# and `default: nil` — legal only on a nullable field — gives the PUT reading
|
|
110
|
+
# where absence also means clear.
|
|
91
111
|
#
|
|
92
112
|
# Failures raise Permittable::InvalidParameters, rescued (on a real
|
|
93
113
|
# controller) into the shared ErrorEnvelope shape with `details:` entries of
|
|
@@ -134,6 +154,9 @@ module Permittable
|
|
|
134
154
|
|
|
135
155
|
LABEL = "Permittable".freeze
|
|
136
156
|
SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
|
|
157
|
+
# Not a scalar: an opaque hash whose shape is deliberately undeclared, for
|
|
158
|
+
# the json/jsonb column a contract has to be able to carry.
|
|
159
|
+
JSON_TYPE = :json
|
|
137
160
|
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
138
161
|
MODES = %i[enforce monitor].freeze
|
|
139
162
|
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
@@ -245,6 +268,31 @@ module Permittable
|
|
|
245
268
|
check_custom(field[:validate], value)
|
|
246
269
|
end
|
|
247
270
|
|
|
271
|
+
# Free-form hash. The shape is deliberately undeclared, so the only
|
|
272
|
+
# checks are the bounds the field asked for: breadth (`length:`, the
|
|
273
|
+
# top-level key count, same reading as an array's element count) and
|
|
274
|
+
# nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
|
|
275
|
+
# checking, like check_scalar.
|
|
276
|
+
def check_json(field, value)
|
|
277
|
+
return [:error, "invalid_type"] unless value.is_a?(Hash)
|
|
278
|
+
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
279
|
+
return [:error, "depth"] if field[:max_depth] && depth_exceeds?(value, field[:max_depth])
|
|
280
|
+
|
|
281
|
+
check_custom(field[:validate], value)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Container nesting, with the field's own hash as level 1. An Array counts
|
|
285
|
+
# as a level too — a deeply nested payload is a deeply nested payload
|
|
286
|
+
# whichever container carries it. Bails at the first breach instead of
|
|
287
|
+
# measuring the whole tree.
|
|
288
|
+
def depth_exceeds?(value, limit)
|
|
289
|
+
return false unless value.is_a?(Hash) || value.is_a?(Array)
|
|
290
|
+
return true if limit < 1
|
|
291
|
+
|
|
292
|
+
children = value.is_a?(Hash) ? value.each_value : value.each
|
|
293
|
+
children.any? { |child| depth_exceeds?(child, limit - 1) }
|
|
294
|
+
end
|
|
295
|
+
|
|
248
296
|
# A custom validator returning a Symbol fails with that symbol as the
|
|
249
297
|
# violation code; false/nil fails as "invalid"; any other truthy value
|
|
250
298
|
# passes.
|
|
@@ -318,16 +366,37 @@ module Permittable
|
|
|
318
366
|
[:error, "invalid_type"]
|
|
319
367
|
end
|
|
320
368
|
|
|
369
|
+
# Date.parse fills in what a string omits FROM TODAY: "09/2026" becomes
|
|
370
|
+
# the 1st, "5th" becomes this month of this year. That is a guess, and a
|
|
371
|
+
# non-deterministic one — the same request means different things on
|
|
372
|
+
# different days — which is exactly what this coercion exists to refuse.
|
|
373
|
+
# So the string must name all three parts; which format it names them in
|
|
374
|
+
# is Date.parse's business, and every complete format it understands
|
|
375
|
+
# ("2026-09-05", "2026/09/05", "Sep 5, 2026") still works.
|
|
321
376
|
def cast_date(value)
|
|
322
377
|
case value
|
|
323
378
|
when Date then [:ok, value]
|
|
324
|
-
when String
|
|
379
|
+
when String
|
|
380
|
+
found = Date._parse(value)
|
|
381
|
+
return [:error, "invalid_type"] unless complete_date?(found)
|
|
382
|
+
|
|
383
|
+
# Built from the components rather than re-running Date.parse, which
|
|
384
|
+
# would parse the same string a second time — and Date.parse is the
|
|
385
|
+
# expensive half. Date.new applies the same calendar validation, so
|
|
386
|
+
# "2026-02-30" still fails.
|
|
387
|
+
[:ok, Date.new(found[:year], found[:mon], found[:mday])]
|
|
325
388
|
else [:error, "invalid_type"]
|
|
326
389
|
end
|
|
327
390
|
rescue ArgumentError, RangeError
|
|
328
391
|
[:error, "invalid_type"]
|
|
329
392
|
end
|
|
330
393
|
|
|
394
|
+
# Date._parse is the layer under Date.parse, and reports which components
|
|
395
|
+
# it actually FOUND rather than the filled-in result.
|
|
396
|
+
def complete_date?(found)
|
|
397
|
+
found.key?(:year) && found.key?(:mon) && found.key?(:mday)
|
|
398
|
+
end
|
|
399
|
+
|
|
331
400
|
# A zoneless String parses as UTC regardless of the host timezone
|
|
332
401
|
# (deterministic); explicit offsets are honoured and normalised to UTC.
|
|
333
402
|
def cast_datetime(value)
|
|
@@ -335,7 +404,17 @@ module Permittable
|
|
|
335
404
|
# DateTime is listed here, ahead of Date, because it subclasses Date.
|
|
336
405
|
when ActiveSupport::TimeWithZone, Time, DateTime then [:ok, value.to_time.utc]
|
|
337
406
|
when Date then [:ok, Time.utc(value.year, value.month, value.day)]
|
|
338
|
-
when String
|
|
407
|
+
when String
|
|
408
|
+
# Same rule as :date — the DATE part must be named in full, or it is
|
|
409
|
+
# taken from today ("10:30" meant today at 10:30). An absent TIME part
|
|
410
|
+
# is fine and means midnight, which is the documented reading of a
|
|
411
|
+
# date given to a :datetime field.
|
|
412
|
+
#
|
|
413
|
+
# Unlike :date this still parses twice, deliberately: rebuilding a
|
|
414
|
+
# Time from components would have to reimplement DateTime.parse's
|
|
415
|
+
# handling of offsets, zone names and sub-second precision, and
|
|
416
|
+
# getting that subtly wrong costs more than the parse.
|
|
417
|
+
complete_date?(Date._parse(value)) ? [:ok, DateTime.parse(value).to_time.utc] : [:error, "invalid_type"]
|
|
339
418
|
else [:error, "invalid_type"]
|
|
340
419
|
end
|
|
341
420
|
rescue ArgumentError, RangeError
|
|
@@ -365,9 +444,13 @@ module Permittable
|
|
|
365
444
|
# declaration is validated eagerly: a bad contract is a programmer error and
|
|
366
445
|
# should fail at class load, not at request time.
|
|
367
446
|
class ContractBuilder
|
|
368
|
-
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
|
|
369
|
-
|
|
370
|
-
|
|
447
|
+
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
|
|
448
|
+
nullable].freeze
|
|
449
|
+
NESTED_OPTS = %i[virtual sensitive message desc nullable].freeze
|
|
450
|
+
JSON_OPTS = %i[length max_depth default validate virtual sensitive transform message desc example
|
|
451
|
+
nullable].freeze
|
|
452
|
+
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example
|
|
453
|
+
nullable].freeze
|
|
371
454
|
|
|
372
455
|
attr_reader :finalizer
|
|
373
456
|
|
|
@@ -437,6 +520,12 @@ module Permittable
|
|
|
437
520
|
field = { name: name, kind: :nested, required: required,
|
|
438
521
|
fields: nested_fields!(name, &block), **opts }
|
|
439
522
|
validate_message!(field)
|
|
523
|
+
elsif type&.to_sym == JSON_TYPE
|
|
524
|
+
assert_opts!(name, opts, JSON_OPTS)
|
|
525
|
+
# `type:` is carried alongside `kind:` so the same `as(:json)` matcher
|
|
526
|
+
# chain and the same error wording work as for a scalar.
|
|
527
|
+
field = { name: name, kind: :json, required: required, type: JSON_TYPE, **opts }
|
|
528
|
+
validate_json_opts!(field)
|
|
440
529
|
else
|
|
441
530
|
assert_opts!(name, opts, SCALAR_OPTS)
|
|
442
531
|
field = { name: name, kind: :scalar, required: required,
|
|
@@ -500,6 +589,40 @@ module Permittable
|
|
|
500
589
|
validate_message!(field)
|
|
501
590
|
end
|
|
502
591
|
|
|
592
|
+
def validate_json_opts!(field)
|
|
593
|
+
name = field[:name]
|
|
594
|
+
if field[:required] && field.key?(:default)
|
|
595
|
+
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
validate_length!(name, field[:length]) if field.key?(:length)
|
|
599
|
+
validate_max_depth!(name, field[:max_depth]) if field.key?(:max_depth)
|
|
600
|
+
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
601
|
+
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
602
|
+
validate_json_authored_value!(field, :default)
|
|
603
|
+
validate_json_authored_value!(field, :example)
|
|
604
|
+
validate_message!(field)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def validate_max_depth!(name, depth)
|
|
608
|
+
return if depth.is_a?(Integer) && depth.positive?
|
|
609
|
+
|
|
610
|
+
raise ArgumentError, "#{LABEL}: :max_depth for :#{name} must be a positive Integer"
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Same rule as a scalar's authored value, over check_json: a `default:` or
|
|
614
|
+
# `example:` that its own bounds would reject fails at class load.
|
|
615
|
+
def validate_json_authored_value!(field, opt)
|
|
616
|
+
return unless field.key?(opt)
|
|
617
|
+
return if authored_nil!(field, opt)
|
|
618
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
|
|
619
|
+
|
|
620
|
+
status, code = Coercion.check_json(field, field[opt])
|
|
621
|
+
return if status == :ok
|
|
622
|
+
|
|
623
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
|
|
624
|
+
end
|
|
625
|
+
|
|
503
626
|
# format / length / normalize reason about characters; on any other
|
|
504
627
|
# type they would silently apply to a cast non-String and mislead.
|
|
505
628
|
def validate_string_only_opts!(field)
|
|
@@ -540,6 +663,7 @@ module Permittable
|
|
|
540
663
|
# shipping it to every request (or publishing it in generated docs).
|
|
541
664
|
def validate_authored_value!(field, opt)
|
|
542
665
|
return unless field.key?(opt)
|
|
666
|
+
return if authored_nil!(field, opt)
|
|
543
667
|
|
|
544
668
|
status, code = Coercion.check_scalar(field, field[opt])
|
|
545
669
|
return if status == :ok
|
|
@@ -549,6 +673,7 @@ module Permittable
|
|
|
549
673
|
|
|
550
674
|
def validate_array_authored_value!(field, opt)
|
|
551
675
|
value = field[opt]
|
|
676
|
+
return if authored_nil!(field, opt)
|
|
552
677
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
553
678
|
return unless field[:of]
|
|
554
679
|
|
|
@@ -560,6 +685,19 @@ module Permittable
|
|
|
560
685
|
end
|
|
561
686
|
end
|
|
562
687
|
|
|
688
|
+
# An authored nil is only meaningful on a nullable field, where it says
|
|
689
|
+
# "absent means clear" (PUT semantics) rather than "no default". On any
|
|
690
|
+
# other field it is a value nil could never satisfy, so it fails at class
|
|
691
|
+
# load with the fix named.
|
|
692
|
+
def authored_nil!(field, opt)
|
|
693
|
+
return false unless field[opt].nil?
|
|
694
|
+
return true if field[:nullable]
|
|
695
|
+
|
|
696
|
+
raise ArgumentError,
|
|
697
|
+
"#{LABEL}: :#{opt} for field :#{field[:name]} is nil but the field is not nullable — " \
|
|
698
|
+
"declare nullable: true to make an explicit null part of the contract"
|
|
699
|
+
end
|
|
700
|
+
|
|
563
701
|
# `message:` customizes what the client reads for a violation on this
|
|
564
702
|
# field: one String covering every code, or a Hash of code => String
|
|
565
703
|
# (codes without an entry keep the default rendering). Keys are
|
|
@@ -694,9 +832,10 @@ module Permittable
|
|
|
694
832
|
end
|
|
695
833
|
|
|
696
834
|
# The drift guard. Nested/array fields are implicitly virtual — only
|
|
697
|
-
# scalar fields
|
|
835
|
+
# scalar fields, and the opaque `:json` field standing in for a
|
|
836
|
+
# json/jsonb column, map one-to-one onto columns.
|
|
698
837
|
def guard_contract_columns!(model_class, fields)
|
|
699
|
-
checked = fields.select { |f| f[:kind]
|
|
838
|
+
checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] }
|
|
700
839
|
return if checked.empty?
|
|
701
840
|
|
|
702
841
|
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
@@ -903,7 +1042,9 @@ module Permittable
|
|
|
903
1042
|
value = hash[key]
|
|
904
1043
|
|
|
905
1044
|
if permittable_absent?(value, hash, key)
|
|
906
|
-
if field
|
|
1045
|
+
if permittable_explicit_null?(field, hash, key)
|
|
1046
|
+
result[key] = nil
|
|
1047
|
+
elsif field.key?(:default)
|
|
907
1048
|
result[key] = field[:default]
|
|
908
1049
|
elsif field[:required]
|
|
909
1050
|
violations << permittable_violation(field, full, "missing")
|
|
@@ -921,13 +1062,9 @@ module Permittable
|
|
|
921
1062
|
key = field[:name].to_s
|
|
922
1063
|
case field[:kind]
|
|
923
1064
|
when :scalar
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
result[key] = out
|
|
928
|
-
else
|
|
929
|
-
violations << permittable_violation(field, full, out)
|
|
930
|
-
end
|
|
1065
|
+
permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
|
|
1066
|
+
when :json
|
|
1067
|
+
permittable_check_whole(field, Coercion.check_json(field, value), full, result, violations: violations)
|
|
931
1068
|
when :nested
|
|
932
1069
|
if value.is_a?(Hash)
|
|
933
1070
|
result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
|
|
@@ -944,6 +1081,19 @@ module Permittable
|
|
|
944
1081
|
end
|
|
945
1082
|
end
|
|
946
1083
|
|
|
1084
|
+
# The shared tail of the two kinds whose entire value is checked in one
|
|
1085
|
+
# call — a scalar, or an opaque hash. A clean value is transformed into the
|
|
1086
|
+
# result; anything else records its code.
|
|
1087
|
+
def permittable_check_whole(field, outcome, full, result, violations:)
|
|
1088
|
+
status, out = outcome
|
|
1089
|
+
if status == :ok
|
|
1090
|
+
out = field[:transform].call(out) if field[:transform]
|
|
1091
|
+
result[field[:name].to_s] = out
|
|
1092
|
+
else
|
|
1093
|
+
violations << permittable_violation(field, full, out)
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
|
|
947
1097
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
948
1098
|
before = violations.length
|
|
949
1099
|
violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
@@ -982,6 +1132,15 @@ module Permittable
|
|
|
982
1132
|
!hash.key?(key) || value.nil? || (value.is_a?(String) && value.empty?)
|
|
983
1133
|
end
|
|
984
1134
|
|
|
1135
|
+
# `nullable: true` splits the one absence rule in two: a key the client
|
|
1136
|
+
# never sent is still absent (defaults apply, required violates), but a key
|
|
1137
|
+
# sent EMPTY is an explicit null — the field yields nil, so a PATCH can
|
|
1138
|
+
# clear a column. Nothing is cast or checked: there is no value to check,
|
|
1139
|
+
# and `transform:` never sees a nil it did not agree to.
|
|
1140
|
+
def permittable_explicit_null?(field, hash, key)
|
|
1141
|
+
field[:nullable] && hash.key?(key)
|
|
1142
|
+
end
|
|
1143
|
+
|
|
985
1144
|
def permittable_check_unknown(fields, hash, path:, unknown:, top_level:, violations:)
|
|
986
1145
|
return if unknown == :ignore
|
|
987
1146
|
|
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.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|