permittable 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +417 -46
- data/lib/permittable/json_schema.rb +196 -0
- data/lib/permittable/open_api.rb +212 -0
- data/lib/permittable/railtie.rb +4 -0
- data/lib/permittable/tasks/openapi.rake +44 -0
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +98 -34
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa679480fdac311694469096c7537926586e987ce2e7a724099611ec91dd3d2b
|
|
4
|
+
data.tar.gz: fa1785c674f0cbf388ce9f74b87f22344adef95cd5a5bfa6c81490fb8958a124
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66e7a85dad6e8029dbc8827b1a55ac14a297e3dc006315ffc66efc0e2f7dd6179ee6247e33f5de93a06d7fe08c3ff1205e30bf6dde6b16d93bfd381bdd3da813
|
|
7
|
+
data.tar.gz: fc0e4a920d62a687c960b808301841327bde86c85a2cf90c36c65e0ac61f133844e0733cd6ad3075298a8680d5a1360e9319495c4e60a9382449047bf6d76c0e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-08-24)
|
|
4
|
+
<!-- title: OpenAPI export -->
|
|
5
|
+
|
|
6
|
+
Contracts gain a third reader. The registry that already drives the validator and the schema-drift guard now also generates **OpenAPI 3.1** — because the schema is emitted from the same frozen data the server enforces, the docs cannot drift from the validation. Fully additive; no behaviour of existing contracts changes.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`Permittable::JsonSchema`** — converts rules and fields into JSON Schema (draft 2020-12): types map onto their canonical JSON encodings (`:decimal` as `["string", "number"]` + `format: decimal`), `in:` → `enum`/`minimum`/`maximum`, `length:` → `minLength`/`maxLength` or `minItems`/`maxItems`, `format:` → `pattern` with `\A`/`\z` translated to `^`/`$`, `default:` → `default`, `unknown: :error` → `additionalProperties: false` at every level, `root:` → a required wrapper object, `sensitive:` → `writeOnly: true`. Required strings get `minLength: 1` (`""` is absent). What has no ECMA/JSON-Schema equivalent stays visible instead of guessed: Ruby-only or flagged regexps export as `x-permittable-pattern`, `validate:`/`transform:` as `x-permittable-custom-validation`/`x-permittable-transformed`, non-numeric Ranges as `x-permittable-range`. Emission is deterministic, so generated documents are committable and diff-stable.
|
|
10
|
+
- **`Permittable::OpenAPI`** — assembles full OpenAPI 3.1 documents (`.document`) and fragments (`.request_body_for`, `.operations_for`, `.components`) from any set of controllers, plain Ruby, no Rails required. Operations resolve through `permit_rule_for`, so last-matching-rule-wins holds in the docs exactly as at request time; every operation references shared components typing the 422 (and, for rooted contracts, 400) error envelope. Catch-all rules expand through `action_methods` (the concern's own public methods excluded) or surface as `"*"` + `x-permittable-catch-all`; unrouted operations land in `x-permittable-controllers` rather than being dropped.
|
|
11
|
+
- **`bin/rails permittable:openapi[output]`** — rake task (loaded by the Railtie) that eager-loads the app, collects every controller with contracts, maps actions onto `paths` via the route set (`:id` → `{id}`), and prints or writes the document. `OPENAPI_TITLE`/`OPENAPI_VERSION` override the `info` block.
|
|
12
|
+
- **`desc:` and `example:` field options, `desc:` on `permit_params`** — documentation passthrough carried on the frozen contract data and ignored by the runtime. An `example:` is validated against its own field's contract at class load, exactly like `default:`, so published examples can't lie either.
|
|
13
|
+
|
|
14
|
+
## 0.2.0 (2026-08-18)
|
|
15
|
+
<!-- title: custom error messages -->
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `message:` field option for customizing violation messages, on every field kind (scalar, array, nested). A String covers every violation code on the field; a Hash of code → String targets specific codes — including Symbol codes returned by `validate:` — while unmatched codes keep the default rendering. A resolved message is carried in the violation detail as `message:` and replaces the `(code)` part of the `InvalidParameters` summary, so it flows into the error envelope and the `invalid_parameters.permittable` instrumentation payload unchanged. An array's message also covers its elements' violations; a malformed `message:` raises at class load like every other contract mistake.
|
|
19
|
+
- `violate!(param, code, message: nil)` — finalize's violation verb accepts the same optional human-readable message.
|
|
20
|
+
|
|
21
|
+
Contracts that don't opt in are byte-for-byte unaffected: details keep the bare `{ param:, code: }` shape and the summary keeps its `param (code)` rendering.
|
|
22
|
+
|
|
3
23
|
## 0.1.2 (2026-08-16)
|
|
4
24
|
<!-- title: gem metadata -->
|
|
5
25
|
|
data/README.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# Permittable
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://rubygems.org/gems/permittable)
|
|
4
|
+
[](https://github.com/VSN2015/permittable/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE.txt)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
**Strong parameters for Rails that also know types, bounds, and defaults.**
|
|
8
|
+
|
|
9
|
+
`params.permit` answers exactly one question: *which keys may pass?* Everything else — is `age` really a number, is `email` shaped like an email, what should `plan` be when the client omits it, and *why* was this request rejected — is left to you, usually as hand-written checks scattered through the action.
|
|
10
|
+
|
|
11
|
+
A Permittable contract answers those questions too. It **casts** each field to a declared type, **validates** it, applies **defaults**, optionally **reshapes** the output, and turns every failure into a machine-readable 422 that names the offending parameter.
|
|
12
|
+
|
|
13
|
+
And because a contract is *class-level data* rather than code inside the action, it can be inspected — and checked against your database when the controller loads, so a column dropped by a migration fails the deploy instead of the request.
|
|
6
14
|
|
|
7
15
|
```ruby
|
|
8
16
|
class UsersController < ApplicationController
|
|
@@ -22,12 +30,12 @@ class UsersController < ApplicationController
|
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
def create
|
|
25
|
-
|
|
33
|
+
User.create!(permitted_params) # cast, validated, defaulted
|
|
26
34
|
end
|
|
27
35
|
end
|
|
28
36
|
```
|
|
29
37
|
|
|
30
|
-
A violating request
|
|
38
|
+
A violating request never reaches your action:
|
|
31
39
|
|
|
32
40
|
```json
|
|
33
41
|
{ "success": false,
|
|
@@ -36,56 +44,280 @@ A violating request renders:
|
|
|
36
44
|
"details": [{ "param": "user.age", "code": "inclusion" }] } }
|
|
37
45
|
```
|
|
38
46
|
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Contents
|
|
50
|
+
|
|
51
|
+
- [Why](#why) · [Installation](#installation) · [How a request flows](#how-a-request-flows)
|
|
52
|
+
- [Declaring a contract](#declaring-a-contract) · [The field DSL](#the-field-dsl) · [Field options](#field-options)
|
|
53
|
+
- [Types and strict coercion](#types-and-strict-coercion) · [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
|
|
54
|
+
- [Violations and error responses](#violations-and-error-responses) · [Custom error messages](#custom-error-messages-message) · [Unknown parameters](#unknown-parameters)
|
|
55
|
+
- [Output reshaping](#output-reshaping-transform-and-finalize) · [The schema-drift guard](#the-schema-drift-guard)
|
|
56
|
+
- [Sensitive parameters](#sensitive-parameters-and-log-redaction) · [Instrumentation](#instrumentation)
|
|
57
|
+
- [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
|
|
58
|
+
- [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Why
|
|
63
|
+
|
|
64
|
+
| | `params.permit` | `params.expect` (Rails 8) | Permittable |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| Filters unknown keys | ✅ | ✅ | ✅ |
|
|
67
|
+
| Requires a root key | via `require` | ✅ | ✅ |
|
|
68
|
+
| Casts to a declared type | ❌ | ❌ | ✅ |
|
|
69
|
+
| Validates bounds, formats, sets | ❌ | ❌ | ✅ |
|
|
70
|
+
| Supplies defaults | ❌ | ❌ | ✅ |
|
|
71
|
+
| Machine-readable error details | ❌ | ❌ | ✅ |
|
|
72
|
+
| Reshapes output | ❌ | ❌ | ✅ |
|
|
73
|
+
| Checked against your schema at boot | ❌ | ❌ | ✅ |
|
|
74
|
+
| Exports OpenAPI / JSON Schema | ❌ | ❌ | ✅ |
|
|
75
|
+
|
|
76
|
+
The design rests on one idea: **a contract is data, not code.** It is declared once at the class level, frozen, inheritable, and introspectable. Everything else here follows from that — the drift guard can read it at boot, `finalize` can run on a bare object with no controller state, and the whole contract can be printed or tested without a request.
|
|
77
|
+
|
|
39
78
|
## Installation
|
|
40
79
|
|
|
41
80
|
```ruby
|
|
42
81
|
gem "permittable"
|
|
43
82
|
```
|
|
44
83
|
|
|
45
|
-
|
|
84
|
+
Then include it wherever you need it — typically once in `ApplicationController`:
|
|
46
85
|
|
|
47
|
-
|
|
86
|
+
```ruby
|
|
87
|
+
class ApplicationController < ActionController::Base
|
|
88
|
+
include Permittable
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The **only runtime dependency is `activesupport`**. `actionpack` (for `rescue_from`, `before_action`, and `ActionController::Parameters`) and `activerecord` (for the `model:` schema-drift guard) are optional — every touchpoint is guarded with `respond_to?`/`defined?`, so your app brings whatever it already has. The concern works on a plain Ruby object that responds to `params`, which is what makes it straightforward to unit-test.
|
|
93
|
+
|
|
94
|
+
> **Naming note:** some legacy stacks (InheritedResources) define their own `permitted_params`. Don't include both on one controller.
|
|
48
95
|
|
|
49
|
-
|
|
96
|
+
## How a request flows
|
|
50
97
|
|
|
51
98
|
```
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
99
|
+
params
|
|
100
|
+
│
|
|
101
|
+
├─ 1. unwrap root: params[:user] → 400 if missing or not a hash
|
|
102
|
+
├─ 2. per field: normalize → cast → validate → transform
|
|
103
|
+
├─ 3. unknown-key check at every nesting level
|
|
104
|
+
├─ 4. finalize only if nothing violated
|
|
105
|
+
│
|
|
106
|
+
└─ permitted_params → HashWithIndifferentAccess (or raises InvalidParameters)
|
|
55
107
|
```
|
|
56
108
|
|
|
57
|
-
|
|
109
|
+
Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
|
|
58
110
|
|
|
59
|
-
##
|
|
111
|
+
## Declaring a contract
|
|
60
112
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
113
|
+
```ruby
|
|
114
|
+
permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &contract)
|
|
115
|
+
```
|
|
64
116
|
|
|
65
117
|
| Option | Default | Meaning |
|
|
66
118
|
|---|---|---|
|
|
67
|
-
| `*actions` | — | Actions the contract covers
|
|
68
|
-
| `root:` | `false` | Key to unwrap first (`require(:user)` equivalent)
|
|
69
|
-
| `model:` | `nil` | Model class
|
|
70
|
-
| `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` —
|
|
71
|
-
| `enforce:` | `false` | `false`
|
|
119
|
+
| `*actions` | — | Actions the contract covers. **No actions = catch-all** for the controller |
|
|
120
|
+
| `root:` | `false` | Key to unwrap first (the `require(:user)` equivalent). Missing or non-hash root → **400** |
|
|
121
|
+
| `model:` | `nil` | Model class, or `true` to infer from `controller_name`, enabling the [drift guard](#the-schema-drift-guard) |
|
|
122
|
+
| `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — how to treat undeclared keys |
|
|
123
|
+
| `enforce:` | `false` | `false` validates lazily on first use; `true` validates in a `before_action` |
|
|
124
|
+
| `desc:` | `nil` | Documentation only — becomes the operation description in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
|
|
125
|
+
|
|
126
|
+
`permit_params` is **repeatable**, and **the last matching rule wins**. Contracts behave like configuration: a base controller declares a catch-all, and a subclass overrides it for specific actions.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class ApiController < ApplicationController
|
|
130
|
+
permit_params(unknown: :error) { optional :page, :integer, in: 1..1000 } # catch-all
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class ReportsController < ApiController
|
|
134
|
+
permit_params :export, root: :report do # wins for #export
|
|
135
|
+
required :format, :string, in: %w[csv pdf]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
|
|
141
|
+
|
|
142
|
+
## The field DSL
|
|
143
|
+
|
|
144
|
+
### Scalars
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
required :name, :string # type defaults to :string
|
|
148
|
+
optional :age, :integer
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Nested hashes
|
|
152
|
+
|
|
153
|
+
Pass a block instead of a type. Violation paths are dotted (`user.address.zip`).
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
optional :address do
|
|
157
|
+
required :city, :string
|
|
158
|
+
optional :zip, :string, format: /\A\d{5}\z/
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Arrays
|
|
163
|
+
|
|
164
|
+
`of:` declares an array of scalars; a block declares an array of hashes. Arrays are **optional unless `required: true`**, `length:` constrains the element **count**, and element failures carry their index (`items[1]`).
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
array :tag_names, of: :string, length: 0..10
|
|
168
|
+
array :line_items, required: true do
|
|
169
|
+
required :sku, :string
|
|
170
|
+
required :quantity, :integer, in: 1..99
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Field options
|
|
175
|
+
|
|
176
|
+
Which options are legal depends on the field kind — anything else raises at class load.
|
|
177
|
+
|
|
178
|
+
| Option | Scalar | Array | Nested | Meaning |
|
|
179
|
+
|---|:---:|:---:|:---:|---|
|
|
180
|
+
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
|
|
181
|
+
| `format:` | ✅¹ | — | — | Regexp the value must match |
|
|
182
|
+
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays |
|
|
183
|
+
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **before** the cast |
|
|
184
|
+
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load |
|
|
185
|
+
| `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
|
|
186
|
+
| `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
|
|
187
|
+
| `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
|
|
188
|
+
| `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
|
|
189
|
+
| `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
|
|
190
|
+
| `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
|
|
191
|
+
| `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
|
|
192
|
+
| `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
|
|
193
|
+
| `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
|
|
194
|
+
|
|
195
|
+
¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
|
|
196
|
+
|
|
197
|
+
`validate:` is the escape hatch for anything the built-ins don't cover:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Types and strict coercion
|
|
204
|
+
|
|
205
|
+
Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`. Rails' casts are lenient by design — `"abc".to_i` is `0`, `Boolean.cast("abc")` is `true` — and silently corrupting untrusted input is precisely what a contract must not do. A value the type cannot faithfully represent is a **violation, not a guess**.
|
|
206
|
+
|
|
207
|
+
| Type | Accepts | Rejects (`invalid_type`) |
|
|
208
|
+
|---|---|---|
|
|
209
|
+
| `:string` | `String`; `Numeric`/`true`/`false` are stringified | Arrays, hashes |
|
|
210
|
+
| `:integer` | `Integer`; whole `Float`s (`4.0`); base-10 numeric strings | `"4.5"`, `"abc"`, `4.5` |
|
|
211
|
+
| `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
|
|
212
|
+
| `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
|
|
213
|
+
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
214
|
+
| `:date` | `Date`; any `Date.parse`-able string | Unparseable strings |
|
|
215
|
+
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`, parseable strings | Unparseable strings |
|
|
216
|
+
|
|
217
|
+
Two behaviours worth committing to memory:
|
|
218
|
+
|
|
219
|
+
- **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
|
|
220
|
+
- **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
|
|
221
|
+
|
|
222
|
+
## Absence, defaults, and partial updates
|
|
223
|
+
|
|
224
|
+
`nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present.
|
|
225
|
+
|
|
226
|
+
That single rule produces the behaviour you want from a `PATCH`:
|
|
227
|
+
|
|
228
|
+
- An **absent optional field is omitted** from the result, so partial updates never nil-out columns.
|
|
229
|
+
- An **absent required field violates** with `missing`.
|
|
230
|
+
- An absent field **with a `default:` gets the default** — so a defaulted field can never report `missing`. (Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality.)
|
|
231
|
+
|
|
232
|
+
Because absence and `nil` are the same thing here, **clearing a column to NULL is outside a contract's vocabulary**. Do that explicitly in the action.
|
|
72
233
|
|
|
73
|
-
|
|
234
|
+
Defaults are checked against the field's own contract when the class loads, so `default: "gold"` on a field declared `in: %w[free pro]` fails at boot rather than on every request.
|
|
74
235
|
|
|
75
|
-
|
|
76
|
-
- A block instead of a type declares a **nested hash**; violation paths are dotted (`user.address.zip`).
|
|
77
|
-
- `array :name, of: :type` (or a block for arrays of hashes) — `length:` constrains the element **count**, element failures carry the index (`items[1]`), `required: true` opts in.
|
|
236
|
+
## Violations and error responses
|
|
78
237
|
|
|
79
|
-
|
|
238
|
+
Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope.
|
|
80
239
|
|
|
81
|
-
|
|
240
|
+
| Code | Raised when |
|
|
241
|
+
|---|---|
|
|
242
|
+
| `missing` | A required field is absent, or the `root:` key is missing (this one is a **400**) |
|
|
243
|
+
| `invalid_type` | The value cannot be faithfully cast to the declared type |
|
|
244
|
+
| `inclusion` | The value is outside `in:` |
|
|
245
|
+
| `format` | The value doesn't match `format:` |
|
|
246
|
+
| `length` | A string's length, or an array's element count, is outside `length:` |
|
|
247
|
+
| `unknown` | An undeclared key was sent while `unknown: :error` |
|
|
248
|
+
| `invalid` | A `validate:` callable returned a falsy value |
|
|
249
|
+
| *your symbol* | A `validate:` callable returned a `Symbol`, or `violate!` was called in `finalize` |
|
|
82
250
|
|
|
83
|
-
|
|
251
|
+
Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
|
|
84
252
|
|
|
85
|
-
|
|
253
|
+
**Status codes:** a missing root key renders **400** (the request is malformed — the envelope you asked for isn't there); field-level violations render **422** (well-formed, semantically wrong).
|
|
86
254
|
|
|
87
|
-
|
|
88
|
-
|
|
255
|
+
**Custom rendering:** if your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when details exist, so hosts documenting a three-keyword contract keep working. Otherwise the inline JSON shape shown at the top of this README is rendered. Either way, `render_invalid_parameters` is a normal method you can override.
|
|
256
|
+
|
|
257
|
+
## Custom error messages (`message:`)
|
|
258
|
+
|
|
259
|
+
Violations stay machine-first — the `code` is the contract — but any field can attach human-readable copy with `message:`. A **String** covers every code on the field; a **Hash of code → String** targets specific codes, and codes without an entry keep the default rendering:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
permit_params :create, root: :user do
|
|
263
|
+
required :email, :string, format: URI::MailTo::EMAIL_REGEXP,
|
|
264
|
+
message: { missing: "is required", format: "must be a valid email address" }
|
|
265
|
+
optional :age, :integer, in: 18..120, message: "must be between 18 and 120"
|
|
266
|
+
array :tags, of: :string, length: 0..10, message: "must be at most ten tags"
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
A resolved message rides into the violation detail and replaces the `(code)` part of the exception's summary line, so both the envelope's `message` and its `details` read naturally:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{ "success": false,
|
|
274
|
+
"error": { "message": "Invalid parameters: user.email must be a valid email address",
|
|
275
|
+
"code": "invalid_parameters",
|
|
276
|
+
"details": [{ "param": "user.email", "code": "format",
|
|
277
|
+
"message": "must be a valid email address" }] } }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The rules:
|
|
281
|
+
|
|
282
|
+
- Messages are written to read after the param name: `"is required"`, not `"Email is required"`.
|
|
283
|
+
- A Hash key matches the violation code, **including Symbol codes returned by `validate:`** — `validate: ->(v) { v.even? || :must_be_even }, message: { must_be_even: "must be an even number" }`.
|
|
284
|
+
- An array's message covers the array's own violations (`length`, `invalid_type`, `missing`) **and** its elements' (`tags[3]`); sub-fields of a nested block resolve their own `message:` declarations.
|
|
285
|
+
- `violate!` in `finalize` takes the same idea as a keyword: `violate!("user.ends_at", :before_start, message: "must be after starts_at")`.
|
|
286
|
+
- A `message:` that is neither a String nor a code → String Hash raises at class load, like every other contract mistake.
|
|
287
|
+
|
|
288
|
+
Fields without a `message:` are untouched — their details keep the bare `{ param:, code: }` shape. For full control over the response body itself (localization, RFC 9457, a different envelope), override `render_invalid_parameters` or define `render_error` as described above; `error.details` gives you the structured violations to build from.
|
|
289
|
+
|
|
290
|
+
## Unknown parameters
|
|
291
|
+
|
|
292
|
+
`unknown:` decides what happens to keys you never declared, **at every nesting level**.
|
|
293
|
+
|
|
294
|
+
| Mode | Behaviour |
|
|
295
|
+
|---|---|
|
|
296
|
+
| `:ignore` (default) | Silently dropped, exactly like strong parameters |
|
|
297
|
+
| `:log` | Dropped, with a `logger.warn` naming the full paths |
|
|
298
|
+
| `:error` | Each undeclared key becomes an `unknown` violation |
|
|
299
|
+
|
|
300
|
+
Rails merges `controller`, `action`, and `format` into `params`; these are exempt at the top level so `unknown: :error` doesn't flag the router's own bookkeeping. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there.
|
|
301
|
+
|
|
302
|
+
## Output reshaping (`transform:` and `finalize`)
|
|
303
|
+
|
|
304
|
+
This is the safe replacement for params-mutating `before_action`s. **Both layers operate on the validated copy — the request's `params` is never touched.**
|
|
305
|
+
|
|
306
|
+
### `transform:` — per field
|
|
307
|
+
|
|
308
|
+
A callable applied **after** cast and validation, reshaping one field's output:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
required :tags, :string, transform: ->(v) { v.split(",") }
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
It runs only on request-supplied values. Absent fields stay absent, `default:` values are authored in their final shape, and a **partially-invalid array is never transformed** — user code is never handed garbage it didn't agree to see.
|
|
315
|
+
|
|
316
|
+
### `finalize` — per contract
|
|
317
|
+
|
|
318
|
+
Declared once, at the top level only. It runs after every field has validated cleanly, receives the result hash, and must return the final `Hash`. Use it to combine parallel fields, build value objects, or drop scaffolding keys.
|
|
319
|
+
|
|
320
|
+
It executes on a **bare runner, not the controller**, so contracts stay pure data plus pure functions and can never grow a dependency on request state. Its one extra verb is `violate!(param, code, message: nil)`, which records a violation (the optional [`message:`](#custom-error-messages-message) rides into the detail) and **halts the block immediately** — so the code after a `violate!` may assume the invariant it just checked. That makes `finalize` the natural home for cross-field validation (`ends_at` after `starts_at`, matching array lengths).
|
|
89
321
|
|
|
90
322
|
```ruby
|
|
91
323
|
permit_params :create, root: :lease_addendum_form do
|
|
@@ -93,36 +325,175 @@ permit_params :create, root: :lease_addendum_form do
|
|
|
93
325
|
required :signer_names, :string, transform: ->(v) { v.split(",") }
|
|
94
326
|
|
|
95
327
|
finalize do |p|
|
|
96
|
-
|
|
97
|
-
|
|
328
|
+
unless p[:signer_names].length == p[:resident_signatures].length
|
|
329
|
+
violate!("lease_addendum_form.signer_names", :length_mismatch)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
p[:signatures] = p[:resident_signatures].zip(p[:signer_names]).map do |image, name|
|
|
333
|
+
Signature.new(image: image, full_name: name)
|
|
334
|
+
end
|
|
98
335
|
p.except(:resident_signatures, :signer_names)
|
|
99
336
|
end
|
|
100
337
|
end
|
|
101
338
|
```
|
|
102
339
|
|
|
103
|
-
|
|
340
|
+
Forgetting to return the hash raises an `ArgumentError` telling you exactly that.
|
|
341
|
+
|
|
342
|
+
## The schema-drift guard
|
|
343
|
+
|
|
344
|
+
This is why `model:` exists. Pass a model class (or `true` to infer it from `controller_name`) and **every non-virtual scalar field is checked against the model's columns when the macro runs** — that is, at controller class load.
|
|
345
|
+
|
|
346
|
+
Production eager-loads controllers, so a column dropped by a migration **fails the deploy, not the request**:
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
Permittable: 'nickname' does not exist in the database (table: users).
|
|
350
|
+
Add it with: bin/rails generate migration AddNicknameToUsers nickname:string
|
|
351
|
+
If this parameter is not backed by a column, declare it with virtual: true.
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The error carries a ready-to-paste migration command, typed from your own field declaration.
|
|
355
|
+
|
|
356
|
+
- **Fields not backed by a column** — `password_confirmation`, terms checkboxes, search filters — opt out with `virtual: true`.
|
|
357
|
+
- **Nested and array fields are implicitly virtual**, since only scalars map one-to-one onto columns.
|
|
358
|
+
- **The check skips when the schema is unreachable** (`db:create`, a fresh `db:migrate`, `assets:precompile`, CI bootstrap), so controller classes stay loadable. Skipping is self-healing: once the migration runs and classes reload, the check happens for real. A missing column with a *reachable* schema still raises — the rescue is scoped to `ActiveRecord::ActiveRecordError` precisely so real bugs keep surfacing.
|
|
359
|
+
|
|
360
|
+
In CI, one spec calling `Rails.application.eager_load!` exercises every contract in the whole app.
|
|
361
|
+
|
|
362
|
+
## Sensitive parameters and log redaction
|
|
363
|
+
|
|
364
|
+
Mark a field `sensitive: true` and its name is registered with `Permittable.filter_parameter_registry`; `Permittable::Railtie` appends a filter proc to `config.filter_parameters` at boot.
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
optional :ssn, :string, sensitive: true
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The indirection is deliberate. Appending plain symbols to `config.filter_parameters` at class-load time misses every consumer that snapshots the list at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers, precompiled filters. A **single proc appended once at boot, consulting a live registry at filter time**, means fields registered when a controller loads later (lazy loading in development) are still redacted. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
|
|
371
|
+
|
|
372
|
+
Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own.
|
|
373
|
+
|
|
374
|
+
## Instrumentation
|
|
375
|
+
|
|
376
|
+
Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
|
|
380
|
+
payload[:controller] # "users"
|
|
381
|
+
payload[:action] # "create"
|
|
382
|
+
payload[:details] # [{ param: "user.age", code: "inclusion" }]
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Exporting OpenAPI (docs that cannot drift)
|
|
104
387
|
|
|
105
|
-
|
|
106
|
-
- `enforce_params_contract` — the `before_action` entry point (skip with `skip_before_action`); only validates rules declared with `enforce: true`.
|
|
107
|
-
- `render_invalid_parameters(error)` — the `rescue_from` target; renders via the host's `render_error` when defined, the identical inline envelope otherwise.
|
|
108
|
-
- Class-side introspection: `permittable_contracts` and `permit_rule_for(action)`.
|
|
109
|
-
- `Permittable.filter_parameter_registry` — duck-typed, swappable sink for `sensitive:` field names; `Permittable::Railtie` appends its live filter proc to `config.filter_parameters`.
|
|
388
|
+
Because a contract is data, it has a third reader beyond the validator and the drift guard: an exporter that emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema). The schema is generated from the same frozen data the server enforces, so — like the drift guard, pointed outward — the docs cannot lie:
|
|
110
389
|
|
|
111
|
-
|
|
390
|
+
```sh
|
|
391
|
+
bin/rails permittable:openapi # JSON to stdout
|
|
392
|
+
bin/rails "permittable:openapi[openapi/api.json]" # write to a file
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
|
|
396
|
+
|
|
397
|
+
Or build fragments programmatically — no Rails required:
|
|
112
398
|
|
|
113
|
-
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
399
|
+
```ruby
|
|
400
|
+
Permittable::JsonSchema.rule(UsersController.permit_rule_for(:create)) # request-body schema
|
|
401
|
+
Permittable::OpenAPI.request_body_for(UsersController, :create) # OpenAPI requestBody object
|
|
402
|
+
Permittable::OpenAPI.operations_for(UsersController) # { action => operation }
|
|
403
|
+
Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
How contracts map:
|
|
407
|
+
|
|
408
|
+
| Contract | Emitted schema |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `required` / `optional` | the object's `required:` array; required strings also get `minLength: 1` (`""` is absent) |
|
|
411
|
+
| `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
|
|
412
|
+
| `:date` / `:datetime` | `string` + `format: date` / `date-time` |
|
|
413
|
+
| `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
|
|
414
|
+
| `in:` Array / numeric Range | `enum` / `minimum` + `maximum` (exclusive ends honoured) |
|
|
415
|
+
| `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
|
|
416
|
+
| `format:` | `pattern`, with `\A`/`\z` translated to `^`/`$` |
|
|
417
|
+
| `default:` / `desc:` / `example:` | `default` / `description` / `examples` |
|
|
418
|
+
| nested block / `array` | `object` + `properties` / `array` + `items` |
|
|
419
|
+
| `unknown: :error` | `additionalProperties: false`, at every nesting level |
|
|
420
|
+
| `root:` | the wrapping object, itself required |
|
|
421
|
+
| `sensitive: true` | `writeOnly: true` (never echoed in responses) |
|
|
422
|
+
|
|
423
|
+
Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
|
|
424
|
+
|
|
425
|
+
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
426
|
+
|
|
427
|
+
Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
|
|
428
|
+
|
|
429
|
+
## API reference
|
|
430
|
+
|
|
431
|
+
### Instance methods
|
|
432
|
+
|
|
433
|
+
| Method | Purpose |
|
|
434
|
+
|---|---|
|
|
435
|
+
| `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation, or `ArgumentError` when no contract covers the action |
|
|
436
|
+
| `enforce_params_contract` | The `before_action` entry point. Only validates rules declared `enforce: true`. Public, so hosts can `skip_before_action` it |
|
|
437
|
+
| `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
|
|
438
|
+
|
|
439
|
+
### Class methods
|
|
440
|
+
|
|
441
|
+
| Method | Purpose |
|
|
442
|
+
|---|---|
|
|
443
|
+
| `permit_params(*actions, **opts, &contract)` | Declare a contract |
|
|
444
|
+
| `permittable_contracts` | The frozen array of every declared rule — introspectable, testable |
|
|
445
|
+
| `permit_rule_for(action)` | The last rule matching `action`, or `nil` |
|
|
446
|
+
|
|
447
|
+
### Module
|
|
448
|
+
|
|
449
|
+
| | |
|
|
450
|
+
|---|---|
|
|
451
|
+
| `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
|
|
452
|
+
| `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
|
|
453
|
+
| `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
|
|
454
|
+
| `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
|
|
455
|
+
| `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
|
|
456
|
+
|
|
457
|
+
## Errors caught at class load
|
|
458
|
+
|
|
459
|
+
A bad contract is a programmer error, so it fails when the class loads — never at request time. Every message names the field and explains the fix.
|
|
460
|
+
|
|
461
|
+
- A field declared twice in one contract
|
|
462
|
+
- An unknown option for the field's kind, listing what *is* allowed
|
|
463
|
+
- An unknown type, listing the supported ones
|
|
464
|
+
- An unknown `normalize:` preset, listing the presets
|
|
465
|
+
- `format:`, `length:`, or `normalize:` on a non-`:string` field
|
|
466
|
+
- `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
|
|
467
|
+
- `validate:` or `transform:` that isn't callable
|
|
468
|
+
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
|
|
469
|
+
- `required: true` combined with `default:`
|
|
470
|
+
- A field given both a type and a nested block; an array given both `of:` and a block
|
|
471
|
+
- An empty contract, or a nested block declaring no sub-fields
|
|
472
|
+
- `finalize` declared twice, without a block, or inside a nested block
|
|
473
|
+
- `permit_params` without a block, or an invalid `unknown:` mode
|
|
474
|
+
- A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
|
|
475
|
+
|
|
476
|
+
## Compatibility
|
|
477
|
+
|
|
478
|
+
| | |
|
|
479
|
+
|---|---|
|
|
480
|
+
| Ruby | >= 3.2 |
|
|
481
|
+
| Rails / ActiveSupport | >= 5.0, < 9 |
|
|
482
|
+
| Required dependency | `activesupport` only |
|
|
483
|
+
| Optional | `actionpack` (rendering, `before_action`), `activerecord` (drift guard) |
|
|
484
|
+
|
|
485
|
+
Using [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `ConcernsOnRails::Controllers::Permittable` is an alias for this module, and `sensitive:` registrations pool into that gem's shared filter registry.
|
|
118
486
|
|
|
119
487
|
## Development
|
|
120
488
|
|
|
121
489
|
```sh
|
|
122
490
|
bundle install
|
|
123
|
-
bundle exec rspec
|
|
491
|
+
bundle exec rspec # 112 examples
|
|
492
|
+
bundle exec rubocop
|
|
124
493
|
```
|
|
125
494
|
|
|
495
|
+
Releases are automated: bump `lib/permittable/version.rb`, add a `CHANGELOG.md` section, then push a `vX.Y.Z` tag. CI publishes to RubyGems via trusted publishing (OIDC — no API keys stored) and creates the GitHub release.
|
|
496
|
+
|
|
126
497
|
## License
|
|
127
498
|
|
|
128
|
-
MIT.
|
|
499
|
+
[MIT](LICENSE.txt).
|