permittable 0.8.0 → 0.10.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 +155 -0
- data/README.md +361 -33
- data/lib/permittable/audit.rb +268 -0
- data/lib/permittable/authored_values.rb +63 -0
- data/lib/permittable/column_guard.rb +133 -6
- data/lib/permittable/contract.rb +11 -2
- data/lib/permittable/error_envelope.rb +79 -4
- data/lib/permittable/field_group.rb +72 -0
- data/lib/permittable/generator.rb +822 -51
- data/lib/permittable/json_schema/ecma_pattern.rb +248 -0
- data/lib/permittable/json_schema.rb +252 -47
- data/lib/permittable/open_api.rb +247 -42
- data/lib/permittable/railtie.rb +1 -0
- data/lib/permittable/rspec.rb +451 -25
- data/lib/permittable/tasks/audit.rake +34 -0
- data/lib/permittable/tasks/generate.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +985 -116
- metadata +9 -4
data/README.md
CHANGED
|
@@ -178,13 +178,16 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
|
|
|
178
178
|
- [Declaring a contract](#declaring-a-contract)
|
|
179
179
|
- [The field DSL](#the-field-dsl)
|
|
180
180
|
- [Field options](#field-options)
|
|
181
|
+
- [`format:` presets](#format-presets)
|
|
181
182
|
- [Types and strict coercion](#types-and-strict-coercion)
|
|
182
183
|
- [Free-form hashes](#free-form-hashes-json)
|
|
183
184
|
- [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
|
|
184
185
|
- [Explicit nulls](#explicit-nulls-nullable)
|
|
185
186
|
- [Violations and error responses](#violations-and-error-responses)
|
|
186
187
|
- [Custom error messages](#custom-error-messages-message) · [Localizing with I18n](#localizing-default-messages-i18n)
|
|
188
|
+
- [RFC 9457 problem+json](#rfc-9457-problemjson)
|
|
187
189
|
- [Unknown parameters](#unknown-parameters)
|
|
190
|
+
- [Reusing fields](#reusing-fields-permittablefields-and-use)
|
|
188
191
|
- [Output reshaping](#output-reshaping-transform-and-finalize)
|
|
189
192
|
- [The schema-drift guard](#the-schema-drift-guard)
|
|
190
193
|
- [Sensitive parameters and log redaction](#sensitive-parameters-and-log-redaction)
|
|
@@ -192,6 +195,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
|
|
|
192
195
|
- **[Adopting on a live API](#adopting-on-a-live-api)**
|
|
193
196
|
- [Monitor mode](#monitor-mode-roll-out-without-rejecting)
|
|
194
197
|
- [Generating draft contracts](#generating-draft-contracts-permittablegenerate)
|
|
198
|
+
- [Auditing coverage](#auditing-coverage-permittableaudit)
|
|
195
199
|
- **[Beyond the controller](#beyond-the-controller)**
|
|
196
200
|
- [Testing contracts](#testing-contracts-rspec-matchers)
|
|
197
201
|
- [Standalone contracts](#standalone-contracts-no-controller)
|
|
@@ -289,12 +293,12 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
289
293
|
|
|
290
294
|
| Option | Scalar | Array | Nested | Meaning |
|
|
291
295
|
|---|:---:|:---:|:---:|---|
|
|
292
|
-
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or
|
|
293
|
-
| `format:` | ✅¹ | — | — | Regexp the value must match |
|
|
296
|
+
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), or any other object answering `include?` (used as given, and read on every request) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
|
|
297
|
+
| `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
|
|
294
298
|
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
|
|
295
299
|
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
|
|
296
|
-
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load
|
|
297
|
-
| `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
|
|
300
|
+
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth with its sub-fields' own `transform:` applied. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. Either way it is frozen, and each request gets its own deep copy |
|
|
301
|
+
| `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code. On an array it runs only when no element violated — an undeclared key inside an element does not count — and `transform:` runs only when nothing violated at all |
|
|
298
302
|
| `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
|
|
299
303
|
| `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
|
|
300
304
|
| `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
|
|
@@ -324,16 +328,40 @@ The visible consequence: a value that violates *both* its length and its format
|
|
|
324
328
|
optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
|
|
325
329
|
```
|
|
326
330
|
|
|
331
|
+
### `format:` presets
|
|
332
|
+
|
|
333
|
+
The regexps every app writes by hand, named once:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
required :email, :string, format: :email
|
|
337
|
+
required :id, :string, format: :uuid
|
|
338
|
+
optional :website, :string, format: :url
|
|
339
|
+
optional :slug, :string, format: :slug
|
|
340
|
+
optional :host, :string, format: :hostname
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
| Preset | Matches | Exported JSON Schema `format` |
|
|
344
|
+
|---|---|---|
|
|
345
|
+
| `:email` | Exactly `URI::MailTo::EMAIL_REGEXP` — the regexp Rails apps already paste in, so switching to the preset cannot change which addresses an endpoint accepts | `email` |
|
|
346
|
+
| `:uuid` | A canonical `8-4-4-4-12` UUID, either case | `uuid` |
|
|
347
|
+
| `:url` | An `http`/`https` URL. A **shape** check, not a reachability guarantee — but it does reject `javascript:` and other schemes | `uri` |
|
|
348
|
+
| `:slug` | Lowercase, digits, single hyphens between segments | — |
|
|
349
|
+
| `:hostname` | A DNS hostname (label rules, no trailing dot) | `hostname` |
|
|
350
|
+
|
|
351
|
+
A preset carries something a hand-written Regexp cannot: the JSON Schema **`format` keyword** the wider ecosystem understands, so [exported docs](#exporting-openapi-docs-that-cannot-drift) say `"format": "uuid"` rather than only a wall of `pattern`. The `pattern` is still emitted next to it — in draft 2020-12 `format` is an annotation unless a validator opts into asserting it, so the pattern is what actually enforces.
|
|
352
|
+
|
|
353
|
+
An unknown preset name fails at class load, listing the presets. Passing a `Regexp` directly works exactly as before, and the RSpec matcher speaks both spellings: `matching(:email)` asserts the preset, `matching(/re/)` the Regexp.
|
|
354
|
+
|
|
327
355
|
### Types and strict coercion
|
|
328
356
|
|
|
329
357
|
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**.
|
|
330
358
|
|
|
331
359
|
| Type | Accepts | Rejects (`invalid_type`) |
|
|
332
360
|
|---|---|---|
|
|
333
|
-
| `:string` | `String
|
|
334
|
-
| `:integer` | `Integer`; whole `Float`s (`4.0`); base-10 numeric strings | `"4.5"`, `"abc"`, `4.5
|
|
335
|
-
| `:float` | `Numeric`;
|
|
336
|
-
| `:decimal` | `Numeric
|
|
361
|
+
| `:string` | `String`, returned in the encoding it arrived in; `Numeric`/`true`/`false` are stringified | Arrays, hashes, a `String` whose bytes are invalid in its own encoding (`"caf\xC3"`) |
|
|
362
|
+
| `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
|
|
363
|
+
| `:float` | `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "` |
|
|
364
|
+
| `:decimal` | `Numeric`, or a canonical numeric string → `BigDecimal` | Unparseable strings, `"1_000"`, `" 1.5 "` |
|
|
337
365
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
338
366
|
| `: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"`) |
|
|
339
367
|
| `: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"`) |
|
|
@@ -341,8 +369,12 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
341
369
|
|
|
342
370
|
**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.
|
|
343
371
|
|
|
372
|
+
**Strings in other encodings are inspected, never converted.** A String whose bytes are not valid in its **own** encoding (`"caf\xC3"` in UTF-8, a lone UTF-16 surrogate) is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. Any other String keeps its encoding: a `:string` value is handed back exactly as it arrived, so a controller using Rails' `skip_parameter_encoding` or `param_encoding` gets its binary or Shift_JIS text unchanged. The number, boolean and date types parse a UTF-8 **copy** of the text, so UTF-16 `"12"` casts to `12` for an `:integer`; when the text has no UTF-8 reading (a byte Windows-1252 leaves undefined) that is `invalid_type`. `normalize:` and `format:` work on the String in its own encoding. Where a normalizer cannot handle that encoding (`:squish` on UTF-16), the value is left as it is; where a `format:` pattern cannot be applied to it (a non-ASCII pattern against UTF-16 or binary bytes), that is a `format` violation. `in:` compares Strings as Ruby does, encoding included. A `:json` field's contents are not examined.
|
|
373
|
+
|
|
344
374
|
**Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal` has no exponent limit, so `"1e400"` is fine there — but `BigDecimal("NaN")` and `BigDecimal("Infinity")` *succeed* where `Float()` raises, so those literal strings are rejected explicitly.
|
|
345
375
|
|
|
376
|
+
**Numbers are spelled the way a client spells them.** `Integer()`, `Float()` and `BigDecimal()` all read underscore digit separators and surrounding whitespace — conveniences for a number literal in Ruby source — so `"1_8"` would be eighteen and `" 99 "` ninety-nine. A numeric string must instead be canonical: an optional sign, digits, an optional fraction and an optional exponent (`"-12"`, `"007"`, `".5"`, `"1.5e10"`). Anything else is `invalid_type`.
|
|
377
|
+
|
|
346
378
|
Two more behaviours worth committing to memory:
|
|
347
379
|
|
|
348
380
|
- **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.
|
|
@@ -443,7 +475,7 @@ Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
|
|
|
443
475
|
|
|
444
476
|
**Status codes.** A bad root key renders **400** — the request is malformed; the envelope you asked for isn't there, or isn't an object. Field-level violations render **422** — well-formed, semantically wrong. The two root failures are told apart by their code: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}`), `invalid_type` when the client sent it with the wrong shape.
|
|
445
477
|
|
|
446
|
-
**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.
|
|
478
|
+
**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, and [`Permittable.error_format = :problem`](#rfc-9457-problemjson) swaps the whole shape for RFC 9457 problem details. For full control beyond that, `error.details` gives you the structured violations to build from.
|
|
447
479
|
|
|
448
480
|
### Custom error messages (`message:`)
|
|
449
481
|
|
|
@@ -493,6 +525,43 @@ en:
|
|
|
493
525
|
|
|
494
526
|
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.
|
|
495
527
|
|
|
528
|
+
### RFC 9457 problem+json
|
|
529
|
+
|
|
530
|
+
For a public API, the standard shape for an error is [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html). One app-wide setting renders it:
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
# config/initializers/permittable.rb
|
|
534
|
+
Permittable.error_format = :problem
|
|
535
|
+
Permittable.problem_base_uri = "https://api.example.com/problems" # optional
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
```http
|
|
539
|
+
HTTP/1.1 422 Unprocessable Entity
|
|
540
|
+
Content-Type: application/problem+json
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
```json
|
|
544
|
+
{
|
|
545
|
+
"type": "https://api.example.com/problems/invalid-parameters",
|
|
546
|
+
"title": "Invalid parameters",
|
|
547
|
+
"status": 422,
|
|
548
|
+
"detail": "Invalid parameters: user.email (format), user.age (inclusion)",
|
|
549
|
+
"instance": "/users",
|
|
550
|
+
"errors": [
|
|
551
|
+
{ "param": "user.email", "code": "format" },
|
|
552
|
+
{ "param": "user.age", "code": "inclusion" }
|
|
553
|
+
]
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
- **`errors`** is the field-violation extension member, carrying the identical `{ param:, code: }` entries (plus `message:` when the field [declares one](#custom-error-messages-message)) that the default envelope puts in `details`. Nothing about violation reporting changes — only the wrapper.
|
|
558
|
+
- **`title`** describes the problem *type*, not the instance: a missing `root:` is `"Malformed request"` (400), a field violation is `"Invalid parameters"` (422).
|
|
559
|
+
- **`type`** is RFC 9457's default `"about:blank"` until you set `problem_base_uri`, at which point each problem type gets its own URI under it.
|
|
560
|
+
- **`instance`** is the request path, and is omitted rather than guessed when the host can't name one (a params duck, a job).
|
|
561
|
+
- **Setting `:problem` opts out of `render_error` delegation.** A host envelope and a problem document are two answers to the same question, and the explicit setting is the one honoured.
|
|
562
|
+
|
|
563
|
+
The setting is app-wide, not per-contract, because the error format of an API is a property of the API. [Exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) follows it: with `:problem` configured, the shared response components describe the problem schema under `application/problem+json` instead of the envelope under `application/json` — an export runs inside the app that made the setting, so the documented shape can't drift from the rendered one.
|
|
564
|
+
|
|
496
565
|
### Unknown parameters
|
|
497
566
|
|
|
498
567
|
`unknown:` decides what happens to keys you never declared, **at every nesting level**.
|
|
@@ -505,9 +574,60 @@ Resolution order per violation: the field's own `message:` (String, or the Hash
|
|
|
505
574
|
|
|
506
575
|
Under `:log` that bound is the whole record: nothing else names an undeclared key, so beyond the tenth only the count survives. Where you need every name — auditing what a client really sends during a rollout — use `unknown: :error` in monitor mode, which records all of them in `details` and in the instrumentation payload without rejecting the request.
|
|
507
576
|
|
|
508
|
-
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token
|
|
577
|
+
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and `commit` from an ordinary form POST. All seven are exempt at the top level. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router, a form, nor a request.
|
|
578
|
+
|
|
579
|
+
All of this changes what is *checked* only. Monitor mode still hands back the form keys, the path parameters and the wrapper's copy in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `params[:id]` or `_method` itself; only the router's three are dropped there.
|
|
580
|
+
|
|
581
|
+
### Reusing fields (`Permittable.fields` and `use`)
|
|
509
582
|
|
|
510
|
-
|
|
583
|
+
A growing API produces two kinds of duplication: the `address` block three controllers want, and the `update` contract that is the `create` contract with nothing mandatory. A **field group** is a reusable field list — the same frozen data a contract's fields are, without the contract around them.
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
AddressFields = Permittable.fields do
|
|
587
|
+
required :city, :string, length: 1..80
|
|
588
|
+
optional :zip, :string, format: /\A\d{5}\z/
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
UserFields = Permittable.fields do
|
|
592
|
+
required :name, :string
|
|
593
|
+
required :email, :string, format: URI::MailTo::EMAIL_REGEXP
|
|
594
|
+
optional :plan, :string, in: %w[free pro], default: "free"
|
|
595
|
+
optional :address do
|
|
596
|
+
use AddressFields # groups compose
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
class UsersController < ApplicationController
|
|
601
|
+
include Permittable
|
|
602
|
+
|
|
603
|
+
permit_params :create, root: :user, model: User do
|
|
604
|
+
use UserFields
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# PATCH: the same fields, nothing mandatory.
|
|
608
|
+
permit_params :update, root: :user, model: User do
|
|
609
|
+
use UserFields, optional: true
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
`use` splices the group in **at the point of use**, in the group's own order, exactly as if the fields had been typed there — so the request-time behaviour, the [drift guard](#the-schema-drift-guard), `sensitive:` registration and the [exported schema](#exporting-openapi-docs-that-cannot-drift) are all identical to the inline spelling. It works at the top level of a contract, inside a nested or array block, and inside another group.
|
|
615
|
+
|
|
616
|
+
| Option | Meaning |
|
|
617
|
+
|---|---|
|
|
618
|
+
| `optional: true` | Relax every spliced field. **Top level only** — if a client sends an `address` at all, the address's own required sub-fields still hold. Types, bounds and `default:` are untouched, so `use UserFields, optional: true` is a complete `PATCH` contract |
|
|
619
|
+
| `only:` / `except:` | Select a subset, in the group's own order. Mutually exclusive |
|
|
620
|
+
|
|
621
|
+
Because a group is built by the same builder a contract is, **every declaration is validated when the group is defined** — a typo fails once, at the group, instead of at each contract that uses it. Two more things fail at class load rather than silently: `only:`/`except:` naming a field the group doesn't declare (so a typo can't quietly drop a field), and a field declared twice. That last one makes overriding deliberate:
|
|
622
|
+
|
|
623
|
+
```ruby
|
|
624
|
+
permit_params :create do
|
|
625
|
+
use AddressFields, except: %i[city]
|
|
626
|
+
required :city, :string, length: 1..5 # this contract's own stricter city
|
|
627
|
+
end
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
A group is deliberately **not** a contract: it has no `root:`, `unknown:`, `model:` or `mode:` — those describe the request being validated, not a set of fields — and `finalize` is rejected for the same reason. A [standalone `Contract`](#standalone-contracts-no-controller) does answer `#fields`, though, so `use SomeContract` lets a webhook payload and a controller action share one definition instead of two that drift.
|
|
511
631
|
|
|
512
632
|
### Output reshaping (`transform:` and `finalize`)
|
|
513
633
|
|
|
@@ -519,7 +639,7 @@ This is the safe replacement for params-mutating `before_action`s. **Both layers
|
|
|
519
639
|
required :tags, :string, transform: ->(v) { v.split(",") }
|
|
520
640
|
```
|
|
521
641
|
|
|
522
|
-
It runs only on request-supplied values. Absent fields stay absent, `default:`
|
|
642
|
+
It runs only on request-supplied values. Absent fields stay absent, and a `default:` is handed out exactly **as authored** — validated against the field's own contract at class load like any default, but neither cast nor transformed (a request that sends the default's value gets it transformed, one that omits the field does not). So author such a default already in the shape the action should receive: `transform: ->(v) { v.to_i }, default: 25` on a `:string` field hands the action the Integer `25` whether the request sent `"25"` (cast, then transformed) or omitted the field (already the final Integer). A field with no `transform:` still gets its `default:` cast to the field's own type, as [`default:`](#the-field-dsl) documents. And a **partially-invalid array is never transformed** — user code is never handed garbage it didn't agree to see.
|
|
523
643
|
|
|
524
644
|
**`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.
|
|
525
645
|
|
|
@@ -559,6 +679,48 @@ If this parameter is not backed by a column, declare it with virtual: true.
|
|
|
559
679
|
|
|
560
680
|
The error carries a ready-to-paste migration command, typed from your own field declaration.
|
|
561
681
|
|
|
682
|
+
#### Checking types too (opt-in)
|
|
683
|
+
|
|
684
|
+
A dropped column fails the deploy; a **retyped** one doesn't, unless you ask:
|
|
685
|
+
|
|
686
|
+
```ruby
|
|
687
|
+
# config/initializers/permittable.rb
|
|
688
|
+
Permittable.check_column_types = true
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
```
|
|
692
|
+
Permittable: 'placed_at' is declared :string but the column is :datetime (table: orders).
|
|
693
|
+
Change the contract to match the column, migrate the column to match the contract,
|
|
694
|
+
or declare the field virtual: true if it is not backed by this column.
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
**It is off by default on purpose.** Every cross-type declaration has some legitimate use — a `:string` contract on a `date` column that lets ActiveRecord do the casting, a `:boolean` contract on a legacy integer column — and breaking those apps on an upgrade would cost more than the drift it catches. Turn it on and fix what it finds.
|
|
698
|
+
|
|
699
|
+
When enabled it compares **groups**, not exact types, so it fires on a genuine cross-family mismatch and stays quiet otherwise:
|
|
700
|
+
|
|
701
|
+
| Group | Column types |
|
|
702
|
+
|---|---|
|
|
703
|
+
| text | `string`, `text`, `citext`, `uuid`, `enum`, `char` |
|
|
704
|
+
| numeric | `integer`, `bigint`, `float`, `decimal`, **`boolean`** |
|
|
705
|
+
| temporal | `date`, `datetime`, `time`, `timestamp`, `timestamptz` |
|
|
706
|
+
|
|
707
|
+
`boolean` sits with the numerics because a boolean stored as an integer `0`/`1` is a real legacy pattern and ActiveRecord casts cleanly between them; the temporal types are one group because a `:date` contract on a `datetime` column is a narrowing, not drift.
|
|
708
|
+
|
|
709
|
+
Any column type **not** in that table — `json`, `jsonb`, `binary`, an adapter's own `inet` or `money` — is never checked. A contract has no faithful type for those, so whatever you improvised is left alone rather than guessed about.
|
|
710
|
+
|
|
711
|
+
**A Rails `enum` is compared by what clients send, not what the column stores.** An enum is submitted by name — `status: "shipped"` — so `optional :status, :string, in: Order.statuses.keys` is the right contract for an integer-backed enum, and a text declaration on any attribute in the model's `defined_enums` is not held to the column's group. It **must** carry that `in:`, though: assignment raises `ArgumentError` for a value the enum does not map, so without one `status: "bogus"` would pass the contract and become a 500 in the action. A text declaration with no `in:`, or with an `in:` listing anything the enum would refuse, fails at class load:
|
|
712
|
+
|
|
713
|
+
```
|
|
714
|
+
Permittable: 'status' is an enum on Order, declared :string without an in: (table: orders).
|
|
715
|
+
A value outside the enum would pass the contract and then raise on assignment.
|
|
716
|
+
Declare it with in: Order.statuses.keys.
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
The `in:` may list names and, for a string-backed enum, stored values, since assignment accepts both. It must be a list: a Range is refused because it cannot be checked. String-backed enums follow the same rule. Other declarations are still held to the column's own group: `:integer` on an integer-backed enum passes, and `:datetime` fails with a suggestion of the enum contract rather than `virtual: true`.
|
|
720
|
+
|
|
721
|
+
The attribute API is **not** treated the same way, by choice. `attribute :starts_at, :datetime` over a string column is still compared against the string column. An enum's mapping says exactly which strings are valid, so the exemption can demand a matching `in:`. An attribute override gives the guard nothing comparable to check the contract against, so exempting it would only switch the check off for that field. Declare such a field to match its column, or leave the check off.
|
|
722
|
+
|
|
723
|
+
|
|
562
724
|
- **Fields not backed by a column** — `password_confirmation`, terms checkboxes, search filters — opt out with `virtual: true`.
|
|
563
725
|
- **Nested and array fields are implicitly virtual**, since only scalars map one-to-one onto columns.
|
|
564
726
|
- **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.
|
|
@@ -663,36 +825,120 @@ Monitor-mode rules validate **eagerly in the `before_action`, regardless of `enf
|
|
|
663
825
|
|
|
664
826
|
### Generating draft contracts (`permittable:generate`)
|
|
665
827
|
|
|
666
|
-
The blank-page problem, solved: the first draft of every contract is generated from what the app already knows.
|
|
828
|
+
The blank-page problem, solved: the first draft of every contract is generated from what the app already knows — the model's columns, and the params calls already sitting in the controller, in either spelling (`params.require(...).permit(...)` or Rails 8's `params.expect(...)`).
|
|
667
829
|
|
|
668
830
|
```sh
|
|
669
831
|
bin/rails permittable:generate # every controller without a contract
|
|
670
832
|
bin/rails "permittable:generate[UsersController]" # one controller, even if covered
|
|
671
833
|
```
|
|
672
834
|
|
|
673
|
-
For each controller the task infers the model from `controller_name` (columns give types, NOT NULL gives `required`), scans the controller source for `params.require(...).permit(...)` calls (permitted keys give the field list and the `root:`), and prints a paste-ready draft:
|
|
835
|
+
For each controller the task infers the model from `controller_name` (columns give types, NOT NULL gives `required`), scans the controller source for `params.require(...).permit(...)` and `params.expect(...)` calls (permitted keys give the field list and the `root:`), and prints a paste-ready draft:
|
|
674
836
|
|
|
675
837
|
```ruby
|
|
676
838
|
# Drafted by permittable:generate — review the TODOs, then deploy: monitor
|
|
677
839
|
# mode reports violations (instrumentation + log) without rejecting requests.
|
|
678
|
-
permit_params :create,
|
|
840
|
+
permit_params :create, root: :user, model: User, mode: :monitor do
|
|
679
841
|
required :name, :string
|
|
680
842
|
optional :age, :integer
|
|
681
|
-
optional :status, :string # database default: "active"
|
|
843
|
+
optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform:
|
|
844
|
+
optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
|
|
845
|
+
array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# :update has nothing required — a PATCH sends only the fields it changes.
|
|
849
|
+
permit_params :update, root: :user, model: User, mode: :monitor do
|
|
850
|
+
optional :name, :string
|
|
851
|
+
optional :age, :integer
|
|
852
|
+
optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform:
|
|
682
853
|
optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
|
|
683
854
|
array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
|
|
684
855
|
end
|
|
685
856
|
```
|
|
686
857
|
|
|
858
|
+
A scanned draft keeps the root its permit call names (a rootless `params.permit(...)` stays rootless). Only a draft with no permit call to scan — drafted from the columns alone — takes its root from the model: `User.model_name.param_key`, the key Rails forms submit under, so a namespaced `Blog::Post` is rooted at `:blog_post`.
|
|
859
|
+
|
|
687
860
|
The generator's one rule is **draft, don't guess** — everything it cannot know for sure stays visible instead of silently decided:
|
|
688
861
|
|
|
689
862
|
- Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
|
|
690
|
-
- A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no
|
|
863
|
+
- A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no faithful representation (`binary`, geometry types) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
|
|
691
864
|
- **Comments are not code.** A commented-out `params.require(:admin).permit(:superuser)` kept for reference is skipped, so it can't contribute a root or a field to the draft. The source is tokenised with `Ripper` for this, because `#` is only sometimes a comment — a permit call inside `#{'#{...}'}` interpolation is live code and is still read, and quoted keys like `permit("name")` still work.
|
|
865
|
+
- NOT NULL is only true of a **create**. When a column makes a field `required`, the draft splits into a `:create` rule and an `:update` rule with every field optional, so a PATCH carrying only the edited field is not rejected for what it left out. With nothing required it stays one `:create, :update` rule. A default the **model** declares (`attribute :plan, default: "free"`, `enum ..., default: :pending`) keeps a NOT NULL column optional just as a database default does, and is shown as `# model default:`, written as declared rather than cast through the attribute type — a `Proc` default is named, never called.
|
|
866
|
+
- A Rails `enum` drafts as the keys a form sends (`:string, in: User.statuses.keys`), not the integer it is stored as — and reads them from the model, so a new enum value cannot leave the contract behind. For an integer-backed enum, Rails also accepts the stored integer (`status: 1`), which JSON clients sometimes send; a TODO on the line says how to admit it.
|
|
867
|
+
- The STI inheritance column (`type`, when the model actually uses STI) and the optimistic-locking column (`lock_version`, when `lock_optimistically` is on) are **not** drafted from the columns alone: assigning `type` changes the record's class. Each is named in a TODO saying why, so the omission is visible. When the controller's own permit call lists one, it **stays a field**, with a TODO — an edit form that round-trips `lock_version` is how Rails detects a stale update, and dropping it would switch that off the day the draft is enforced.
|
|
692
868
|
- A database default is noted in a comment but **not** copied into `default:` — a contract default is injected on every request that omits the field, which would overwrite columns on partial updates. The database already handles creation.
|
|
693
|
-
- `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes.
|
|
869
|
+
- `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes. In a `params.expect` call the two shapes are distinguishable — `key: [:a]` is a nested hash, `key: [[:a]]` is an array of hashes — so that draft carries no TODO at all.
|
|
870
|
+
- **One contract, one envelope.** A contract has one `root:`, so the generator picks it from every call in the file before it drafts any field:
|
|
871
|
+
1. When the model is known and any call uses its envelope (`post` for `Post`), that envelope wins outright, even `require(:post).permit(*PERMITTED)`.
|
|
872
|
+
2. With no model known, an envelope beats the rootless calls if it has at least one **parsed** field (`*PERMITTED` counts for nothing). An envelope with no parsed field, such as `expect(search: FILTERS)`, beats only rootless calls with no parsed field either. Among the envelopes, the one with more parsed fields wins. Every envelope of an `expect(post: [...], comment: [...])` call counts, and so does `expect(post: PERMITTED_PARAMS)`.
|
|
873
|
+
3. With a model that no envelope matches, the rootless calls, taken together, compete too. An envelope wins a tie with them.
|
|
874
|
+
|
|
875
|
+
Remaining ties go to the first call in the source. A single-key `expect` lookup of `:id` or a `*_id` key, like a Rails 8 scaffold's `Post.find(params.expect(:id))`, is a route param. It never scores and is never drafted as a field. A single-key `params.permit(:group_id)` is mass assignment and is drafted as usual. Only the winner's calls become fields, and a key the winner drafts is never also a TODO. Everything else stays visible as a TODO that says why:
|
|
876
|
+
- `belongs to another envelope (search): params.require(:search).permit(:q)` for a losing envelope, quoted as the call (or, inside an `expect`, the argument) the source spells it with.
|
|
877
|
+
- `route or query param, not a body field: :id` for a route param, or a bare key beside the envelope in the same `expect` call.
|
|
878
|
+
- `outside the post envelope, so not in this contract: :page` for a rootless call's keys once an envelope wins, or an array or hash beside the envelope.
|
|
694
879
|
|
|
695
|
-
|
|
880
|
+
A file with only rootless calls drafts a rootless contract.
|
|
881
|
+
- **A draft always declares a field, or there is no draft.** Sometimes no scanned line would declare a field: every call is `permit(*PERMITTED)`, or the only scanned key is a `binary` column, which has no contract type. A contract of only TODO lines would raise `a contract must declare at least one field` when pasted. So the columns are drafted instead, with the scan's TODOs underneath. Columns the TODOs say are not drafted (a route param, or a key permitted in shapes that accept different input) are left out. A rootless controller whose calls carried a body field gets a rootless draft, with the columns at the top level. A scan that found only a route-param lookup gets the model's root. If the columns cannot declare a field either, the next candidate root is tried — the model's envelope permitting only a `binary` column does not stop the file's other envelope from being drafted — and only when no candidate can be drafted is there no draft.
|
|
882
|
+
- **A key permitted in two shapes is drafted once.** A contract rejects a field declared twice. A nested hash and an array of hashes merge into the array of hashes, keeping the sub-keys of both. An array of scalars (`tags: []`) and a hash shape (`tags: [:a]`) accept different input, so neither is drafted, and the TODO asks you to declare the shape the actions share. Otherwise the richer shape wins over a scalar. Every conflict is named in a TODO that lists each shape and what was drafted (`tags is permitted as both a scalar and an array — drafted as the array`). An empty list such as `meta: [[ ]]` is kept as a TODO, never drafted as an empty block.
|
|
883
|
+
|
|
884
|
+
No Rails required for the core: `Permittable::Generator.draft(model: User)`, `.for_controller(controller, source: File.read(path))`, and `.scan(source, model: User)` are plain Ruby.
|
|
885
|
+
|
|
886
|
+
---
|
|
887
|
+
|
|
888
|
+
### Auditing coverage (`permittable:audit`)
|
|
889
|
+
|
|
890
|
+
A controller declaring `permit_params :create` looks adopted. If it also answers `PATCH`, that action is validating **nothing** — and until now nothing in the gem said so. [`permittable:generate`](#generating-draft-contracts-permittablegenerate) only notices controllers with no contract at all, and the [OpenAPI export](#exporting-openapi-docs-that-cannot-drift) documents what exists rather than what is missing.
|
|
891
|
+
|
|
892
|
+
The audit crosses the contract registry with the **route set**, so a half-covered controller is as visible as an uncovered one:
|
|
893
|
+
|
|
894
|
+
```sh
|
|
895
|
+
bin/rails permittable:audit # the table plus a summary
|
|
896
|
+
bin/rails "permittable:audit[strict]" # ...and exit 1 on any unguarded write action
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
```
|
|
900
|
+
legacy/invoices
|
|
901
|
+
POST /legacy/invoices create no contract — ACCEPTS A BODY
|
|
902
|
+
orders
|
|
903
|
+
POST /orders create enforce
|
|
904
|
+
DELETE /orders/{id} destroy no contract — action not found
|
|
905
|
+
PUT /orders/{id} update no contract — ACCEPTS A BODY
|
|
906
|
+
users
|
|
907
|
+
GET /users index no contract
|
|
908
|
+
POST /users create enforce model: User unknown: error
|
|
909
|
+
DELETE /users/{id} destroy monitor
|
|
910
|
+
PATCH /users/{id} update enforce model: User unknown: error
|
|
911
|
+
|
|
912
|
+
8 routed actions: 3 enforced, 1 in monitor mode, 3 without a contract, 1 not found (Rails 404s it)
|
|
913
|
+
2 of those accept a request body — untrusted input reaches the action unchecked
|
|
914
|
+
2 covered actions declare no model:, so no schema-drift guard runs for them
|
|
915
|
+
|
|
916
|
+
Contracts declared for actions no route reaches or Rails would 404 (renamed or deleted?):
|
|
917
|
+
users#archive
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
Three things it tells you that nothing else does:
|
|
921
|
+
|
|
922
|
+
- **Which write actions are unguarded.** A `GET` without a contract is usually fine; a `POST` without one is untrusted input reaching the action unchecked. That count is the number `[strict]` fails on, which makes the task a CI gate: *no new unguarded write endpoint*.
|
|
923
|
+
- **Which contracts aren't enforcing yet.** The audit runs inside the app, so unlike the exported OpenAPI it resolves the **effective** mode — a rule's own `mode:` first, then your app-wide `Permittable.mode`. This is the [monitor-mode](#monitor-mode-roll-out-without-rejecting) rollout dashboard.
|
|
924
|
+
- **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind.
|
|
925
|
+
|
|
926
|
+
A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
|
|
927
|
+
|
|
928
|
+
A catch-all 404 route (`match "*path", to: "application#not_found", via: :all`) shows its POST, PUT and PATCH rows as accepting a body. They do accept one: every stray body reaches the controller. Route only GET to the controller (Rails answers HEAD from it). Send the other verbs to a plain Rack endpoint, which never parses the body and which the audit does not list:
|
|
929
|
+
|
|
930
|
+
```ruby
|
|
931
|
+
match "*path", to: "application#not_found", via: :get
|
|
932
|
+
match "*path", to: ->(_env) { [404, { "content-type" => "text/plain" }, ["Not Found"]] }, via: :all
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
A `config.exceptions_app = routes` setup (`match "/404", to: "errors#not_found", via: :all`) shows the same rows. It needs only `via: :get`, because on 6.1 and later `ShowExceptions` re-dispatches the error request as a GET.
|
|
936
|
+
|
|
937
|
+
Under `[strict]` the task aborts on these rows, so the only choices today are to route the catch-all GET-only, as above, or to run the audit without `[strict]` until the ignore list ([#69](https://github.com/VSN2015/permittable/issues/69)) lands. Don't declare a contract on the catch-all to silence the gate. Under monitor mode it validates eagerly, so a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also gain a fake `/{path}` endpoint.
|
|
938
|
+
|
|
939
|
+
The table lists a row for every verb on every path; the summary counts routes. A route with an optional segment, such as anything under `scope "(:locale)"`, lists each path it expands to (`/users` and `/{locale}/users`), but it is one route, so one unguarded `POST` counts once and the summary line says `N routed actions in M rows`. Rows are collapsed by controller, action, route index and verb; the index is a position within one `rails_routes` call, so audit concatenated route lists (an app's and an engine's) separately, or give them distinct `route:` values. Two separate routes to the same action (`post "/users"` and `post "/admin/users"`) count as two, because each one is a way in.
|
|
940
|
+
|
|
941
|
+
Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails.
|
|
696
942
|
|
|
697
943
|
---
|
|
698
944
|
|
|
@@ -718,10 +964,39 @@ RSpec.describe UsersController do
|
|
|
718
964
|
end
|
|
719
965
|
```
|
|
720
966
|
|
|
721
|
-
Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in
|
|
967
|
+
Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`, cast by the field's type just as the contract's list is, so `within(%i[draft published])` repeats the declaration as written), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`).
|
|
968
|
+
|
|
969
|
+
The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`.
|
|
970
|
+
|
|
971
|
+
It reads the contract, not the runtime mode. In enforce mode an undeclared key never reaches `permitted_params` — it is dropped from it, or the request is rejected under `unknown: :error` — so "not declared" means "not permitted" for code that reads `permitted_params`. The raw `params` still carries every key, as it always does. A rule in [monitor mode](#monitor-mode-roll-out-without-rejecting) is different: `permitted_params` hands back the raw params, so an undeclared `:admin` comes through it until the rule is switched to enforce. The matcher does not fail for that, because a monitor-mode rule is a rollout stage and the spec describes the contract being rolled out; the enforce switch is what makes the assertion hold at runtime.
|
|
722
972
|
|
|
723
973
|
`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.
|
|
724
974
|
|
|
975
|
+
#### Asserting on behaviour, not just the declaration
|
|
976
|
+
|
|
977
|
+
`permit_param` checks what a contract **says**. `accept_params` / `reject_params` check what it **does** — still without dispatching a request:
|
|
978
|
+
|
|
979
|
+
```ruby
|
|
980
|
+
expect(described_class).to accept_params(user: { name: "Jo", email: "a@b.co", age: "30" })
|
|
981
|
+
.for_action(:create)
|
|
982
|
+
.returning("name" => "Jo", "email" => "a@b.co", "age" => 30, "plan" => "free")
|
|
983
|
+
|
|
984
|
+
expect(described_class).to reject_params(user: { name: "Jo", email: "nope" })
|
|
985
|
+
.for_action(:create).with_violation("user.email", :format)
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
`returning` asserts the **cast, defaulted, transformed** output — the part `permit_param` can't reach, since it only reads the declaration. `with_violation` is repeatable and its code is optional.
|
|
989
|
+
|
|
990
|
+
Failure messages name what actually happened:
|
|
991
|
+
|
|
992
|
+
```
|
|
993
|
+
expected UsersController to accept those params, but it rejected them: user.email (missing)
|
|
994
|
+
expected UsersController to reject those params, but it accepted them, returning {"name"=>"Jo", "email"=>"a@b.co"}
|
|
995
|
+
expected UsersController to reject those params with user.age (inclusion), but the violations were: user.name (missing), user.email (missing)
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
Both work on a controller class, a controller instance, or a [standalone `Contract`](#standalone-contracts-no-controller), and both read the **contract** rather than the rollout mode — a [monitor-mode](#monitor-mode-roll-out-without-rejecting) rule still `reject_params`, because the question is what the contract says, not what the deploy currently does with it.
|
|
999
|
+
|
|
725
1000
|
### Standalone contracts (no controller)
|
|
726
1001
|
|
|
727
1002
|
The same DSL, callable on any Hash — webhook payloads, job arguments, service-object inputs, CSV rows:
|
|
@@ -733,7 +1008,7 @@ CreateUser = Permittable::Contract.define(root: :user) do
|
|
|
733
1008
|
optional :plan, :string, in: %w[free pro], default: "free"
|
|
734
1009
|
end
|
|
735
1010
|
|
|
736
|
-
result = CreateUser.call(payload) #
|
|
1011
|
+
result = CreateUser.call(payload) # a Result — bad client input is a violation, not an exception
|
|
737
1012
|
result.valid? # => false
|
|
738
1013
|
result.violations # => [{ param: "user.age", code: "inclusion" }]
|
|
739
1014
|
result.params # validated HashWithIndifferentAccess; nil when invalid
|
|
@@ -743,7 +1018,11 @@ CreateUser.json_schema # the contract as JSON Schema (draft 2020
|
|
|
743
1018
|
CreateUser.rule # the frozen, introspectable rule data
|
|
744
1019
|
```
|
|
745
1020
|
|
|
746
|
-
Everything carries over — strict coercion, `""`/`nil` absence, defaults, `finalize` with `violate!`, `sensitive:` log-redaction registration, `invalid_parameters.permittable` instrumentation, 400-vs-422 status semantics for a missing `root:`.
|
|
1021
|
+
Everything carries over — strict coercion, `""`/`nil` absence, defaults, `finalize` with `violate!`, `sensitive:` log-redaction registration, `invalid_parameters.permittable` instrumentation, 400-vs-422 status semantics for a missing `root:`.
|
|
1022
|
+
|
|
1023
|
+
**What `#call` still raises.** Client data never raises out of the gem's own checks. Wrong types, non-finite numbers, Strings in any encoding (valid or not) and undeclared keys in any encoding all come back as violations in the `Result`. Two things do raise, on purpose, because neither is the client's mistake. An input that is not a Hash, `nil` (read as `{}`) or an object answering `to_unsafe_h` (such as `ActionController::Parameters`) raises `ArgumentError`. And an exception raised by your own code (a `validate:`, `transform:` or `normalize:` proc, or `finalize`) reaches the caller unchanged, since swallowing it would hide a bug. The one exception is a `normalize:` proc that raises `ArgumentError` or an encoding error on a String that is neither UTF-8 nor ASCII-only; that value is left as it is, like a preset's.
|
|
1024
|
+
|
|
1025
|
+
Three differences from the controller concern, all deliberate:
|
|
747
1026
|
|
|
748
1027
|
- **A `Contract` always enforces.** Monitor mode is a request-rollout switch; standalone callers read the `Result` instead, so the app-wide `Permittable.mode` is ignored here.
|
|
749
1028
|
- **No router-key exemption.** `unknown: :error` flags a stray `action` or `controller` key — standalone input has no router to excuse.
|
|
@@ -771,10 +1050,44 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
|
771
1050
|
|
|
772
1051
|
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.
|
|
773
1052
|
|
|
774
|
-
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a
|
|
1053
|
+
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `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 — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
1054
|
+
|
|
1055
|
+
**Every `operationId` is unique across the document, and only a collision is ever renamed.** An operation's id is its controller path with `/` folded to `_`, then its action: `users_create`, `admin_users_index`. Client generators name a method after the id, so the scheme itself never changes. Where two places in the document would carry one id, the exporter renames all but one of them:
|
|
1056
|
+
|
|
1057
|
+
| Collision | Ids |
|
|
1058
|
+
| --- | --- |
|
|
1059
|
+
| One operation under two verbs (the separate PATCH and PUT routes `resources` draws to `update`, or one `match ..., via: [:patch, :put]` route) | `users_update` on PATCH, `users_update_put` on PUT |
|
|
1060
|
+
| The pair again at a second path (`resources :orgs { resources :users }`) | `users_update_2` on the second PATCH, `users_update_3` on the second PUT |
|
|
1061
|
+
| One operation under every verb (with `via: :all` routes, which the exporter documents under each verb) | `webhooks_receive` on GET, then `webhooks_receive_post`, `_put`, `_patch`, `_delete` |
|
|
1062
|
+
| One operation at two paths under one verb (with the optional-segment expansion: `(/:locale)/posts` is documented at `/posts` and `/{locale}/posts`) | `posts_create` on the first path in route order, `posts_create_2` on the other |
|
|
1063
|
+
| Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on the first controller, `admin_users_index_2` on the second |
|
|
1064
|
+
|
|
1065
|
+
One place keeps the plain id. A routed operation comes before one under `x-permittable-controllers`, which takes part because it is in the same document. After that, controller, action and route order decide. Within one operation, PATCH comes before PUT whichever the route lists first, so `match via: [:put, :patch]` and `resources` name the PATCH method the same way. Between two operations only the order counts, whatever the verbs. Every other place gets its verb appended when that verb differs from the plain id's verb and the result is free. Otherwise it gets the next free number, from `_2`. So a second PATCH is `users_update_2`, not `users_update_patch`, and a second POST is `posts_create_2`. **The stability rule:** an id that only one operation would carry never changes, even when a suffix elsewhere would spell it; that suffix is numbered instead. So a change to routes or controllers can rename only operations that collide, never one that stands alone. A route declared twice is placed once, and a controller passed twice is documented once, so neither collides with itself.
|
|
775
1066
|
|
|
776
1067
|
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.
|
|
777
1068
|
|
|
1069
|
+
#### What the schema deliberately does not say
|
|
1070
|
+
|
|
1071
|
+
`spec/schema_conformance_spec.rb` holds the "cannot drift" claim to account: it walks canonical JSON payloads through both the contract and its own exported schema and asserts the verdicts agree.
|
|
1072
|
+
|
|
1073
|
+
Where they legitimately differ, the spec names the reason and asserts the **direction**, so a new divergence fails the suite instead of shipping quietly. Three cases go the safe way — the **server accepts what its docs reject**, leaving a client that follows the docs merely conservative:
|
|
1074
|
+
|
|
1075
|
+
- **Non-canonical encodings.** Coercion accepts `"30"` for an `:integer` and `1` for a `:string`, because form and query payloads are all strings. The schema documents the canonical JSON encoding only.
|
|
1076
|
+
- **`null` as absence.** The runtime reads `{"age": null}` as `{}` ([absence](#absence-defaults-and-partial-updates)); JSON Schema cannot express that, so `type: integer` rejects a null the server would accept and ignore. A [`nullable:`](#explicit-nulls-nullable) field is not this case — there the null is a value, the exported `type` widens to say so, and the two agree.
|
|
1077
|
+
- **Padding that normalizes away.** `normalize:` runs before the checks, so under `normalize: :squish` and `length: 3..10` the server accepts `" abcdefghij "` — ten characters once squished — while the docs reject its fourteen.
|
|
1078
|
+
|
|
1079
|
+
Six cases go the other way, and are worth knowing before you hand the document to a client. Each rule stays visible on its own field, and the spec asserts that as well as the direction:
|
|
1080
|
+
|
|
1081
|
+
- **Bounds JSON Schema has no keyword for.** A `:json` field's `max_depth:` is enforced by the server but cannot be written as a JSON Schema keyword, so the published document is **looser** there and an over-nested payload still earns a 422. The bound is not dropped — it is exported as `x-permittable-max-depth` — so a generator or linter that wants it can read it.
|
|
1082
|
+
- **`normalize:` runs before the checks.** The server validates the *normalized* string, and JSON Schema has no keyword for "transform, then check". With `required :name, :string, length: 3..10, normalize: :squish`, `" "` passes the docs' `minLength: 3` and then squishes to `""` — absent, so `missing` — and `" a "` passes them and squishes to `"a"`, which is too short. The step is exported as `x-permittable-normalize` — the preset's name (`"squish"`, `"email"`, …), which a client can apply before validating, or `true` for a custom proc.
|
|
1083
|
+
- **A bounded `:decimal` sent as a string.** A `:decimal` is documented as `["string", "number"]`, because the string is its precision-safe encoding, but `minimum`/`maximum` constrain only numbers — so `"5000"` passes the docs for `in: BigDecimal("0.01")..BigDecimal("999.99")` and the server answers `inclusion`. The bound is still published, as a JSON number, for a client that parses the string first. (Numbers are published exactly as written, at any magnitude — `10**400` included, since a JSON integer has no size limit — but a client that reads the document back with ordinary double-precision floats, rather than the digits as sent, can still round a value across a boundary. That is inherent to parsing any JSON number as a double, not something this exporter controls.)
|
|
1084
|
+
- **A `validate:` proc.** It is opaque app code, so the schema can only flag it — `x-permittable-custom-validation` — never enforce what it checks. A value the proc refuses still passes the docs.
|
|
1085
|
+
- **A Range of non-numbers.** `in: "a".."m"` has no `minimum`/`maximum` equivalent (those constrain numbers only) and rides along as `x-permittable-range` instead. A value outside it still passes the docs.
|
|
1086
|
+
- **Strings whose validity is a `format`.** `:decimal`, `:date` and `:datetime` are sent as strings, and what makes such a string valid is its `format` (`"decimal"`, `"date"`, `"date-time"`) — which draft 2020-12 treats as an annotation unless a validator opts into asserting it. So `"abc"` or `"NaN"` for a `:decimal` and `"2026-02-30"` for a `:date` pass most validators and fail the server's cast with `invalid_type`.
|
|
1087
|
+
|
|
1088
|
+
A `format:` regexp that does not translate to ECMA-262 is looser in the same way — it publishes as `x-permittable-pattern` rather than a `pattern` that would enforce something else, so a value it refuses still passes the docs — but it is not one of the six above: `spec/schema_conformance_spec.rb` does not yet assert a case for it, since the Ruby → ECMA-262 translation it would depend on is being reworked separately. Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.
|
|
1089
|
+
|
|
1090
|
+
|
|
778
1091
|
<details>
|
|
779
1092
|
<summary><strong>How contracts map onto JSON Schema</strong></summary>
|
|
780
1093
|
|
|
@@ -786,14 +1099,14 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
786
1099
|
| `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
|
|
787
1100
|
| `:date` / `:datetime` | `string` + `format: date` / `date-time` |
|
|
788
1101
|
| `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
|
|
789
|
-
| `in:`
|
|
1102
|
+
| `in:` list / numeric Range | `enum` of the cast members (a `:date`/`:datetime` member written as a String is published as written) / `minimum` + `maximum` (exclusive ends honoured). An `in:` object that only answers `include?` is flagged `x-permittable-custom-validation` |
|
|
790
1103
|
| `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
|
|
791
|
-
| `format:` | `pattern`, with `\A`/`\z`
|
|
1104
|
+
| `format:` | `pattern`, valid under the `u` flag Ajv compiles with: `\A`/`\z` become `^`/`$`, `\s` and `.` are spelled out as the classes they are in Ruby (ECMA-262's `\s` also matches NBSP and U+2028; its `.` also stops at `\r`), and redundant escapes like `\-` and `\#` are written bare |
|
|
792
1105
|
| `default:` / `desc:` / `example:` | `default` / `description` / `examples` |
|
|
793
1106
|
| nested block / `array` | `object` + `properties` / `array` + `items` |
|
|
794
1107
|
| `unknown: :error` | `additionalProperties: false`, at every nesting level |
|
|
795
1108
|
| `root:` | the wrapping object, itself required |
|
|
796
|
-
| `sensitive: true` | `writeOnly: true` (never echoed in responses) |
|
|
1109
|
+
| `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published |
|
|
797
1110
|
|
|
798
1111
|
</details>
|
|
799
1112
|
|
|
@@ -828,12 +1141,18 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
828
1141
|
| `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry; entries already registered are carried across |
|
|
829
1142
|
| `Permittable.filter_parameter_proc` | The single proc `Permittable::Railtie` appends to `config.filter_parameters`; consults the current registry at filter time |
|
|
830
1143
|
| `Permittable.mode` / `Permittable.mode=` | App-wide default (`:enforce`) for rules that don't declare their own `mode:` |
|
|
1144
|
+
| `Permittable.error_format` / `=` | `:envelope` (default) or `:problem` — see [RFC 9457 problem+json](#rfc-9457-problemjson) |
|
|
1145
|
+
| `Permittable.problem_base_uri` / `=` | Base URI for problem `type` members |
|
|
1146
|
+
| `Permittable.check_column_types` / `=` | Opt in to the [type half of the drift guard](#checking-types-too-opt-in) (default `false`) |
|
|
1147
|
+
| `Permittable.fields(&block)` | A reusable [field group](#reusing-fields-permittablefields-and-use) — splice it into a contract with `use` |
|
|
831
1148
|
| `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
|
|
832
1149
|
| `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
|
|
833
1150
|
| `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
|
|
834
1151
|
| `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
|
|
835
|
-
| `Permittable::
|
|
836
|
-
| `Permittable::
|
|
1152
|
+
| `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
|
|
1153
|
+
| `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`, `#fields`) |
|
|
1154
|
+
| `Permittable::FieldGroup` | A [reusable field list](#reusing-fields-permittablefields-and-use) (`#fields`, `#names`) — built by `Permittable.fields` |
|
|
1155
|
+
| `Permittable::Matchers` | RSpec matchers via `require "permittable/rspec"` — `permit_param` for the declaration, `accept_params`/`reject_params` for the behaviour. See [testing contracts](#testing-contracts-rspec-matchers) |
|
|
837
1156
|
|
|
838
1157
|
### Errors caught at class load
|
|
839
1158
|
|
|
@@ -847,9 +1166,11 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
847
1166
|
- A field declared twice in one contract
|
|
848
1167
|
- An unknown option for the field's kind, listing what *is* allowed
|
|
849
1168
|
- An unknown type, listing the supported ones
|
|
850
|
-
- An unknown `normalize:` preset, listing the presets
|
|
1169
|
+
- An unknown `normalize:` or `format:` preset, listing the presets
|
|
1170
|
+
- A `format:` that is neither a `Regexp` nor a preset name
|
|
851
1171
|
- `format:`, `length:`, or `normalize:` on a non-`:string` field
|
|
852
|
-
- `length:` that isn't a non-negative `Integer` or a `Range`; `in:` that
|
|
1172
|
+
- `length:` that isn't a non-negative `Integer` or a `Range`; an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
|
|
1173
|
+
- An `in:` member the field's own type can't cast (`in: %w[1 two]` on an `:integer`, `nil` on a field that isn't `nullable:`, or a `Time` on a `:date` field that isn't exactly midnight UTC), or an `in:` `Range` whose endpoints a value of the field's type can't be compared with (`in: "1".."5"` on an `:integer`) — either would reject every request as `inclusion`
|
|
853
1174
|
- A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`)
|
|
854
1175
|
- `validate:` or `transform:` that isn't callable
|
|
855
1176
|
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept
|
|
@@ -859,10 +1180,13 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
859
1180
|
- `required: true` combined with `default:`
|
|
860
1181
|
- A field given both a type and a nested block; an array given both `of:` and a block
|
|
861
1182
|
- An empty contract, or a nested block declaring no sub-fields
|
|
862
|
-
- `finalize` declared twice, without a block, or inside a
|
|
1183
|
+
- `finalize` declared twice, without a block, inside a nested block, or inside a field group
|
|
1184
|
+
- `use` given something that is not a field group, both `only:` and `except:`, a name the group doesn't declare, or a selection that keeps nothing
|
|
1185
|
+
- A field group with no fields, or `Permittable.fields` without a block
|
|
863
1186
|
- `permit_params` without a block, or an invalid `unknown:` mode
|
|
864
1187
|
- A `root:` that isn't a single key (several top-level envelopes are a rootless contract with one nested block per key)
|
|
865
|
-
- An invalid `mode:` (and `Permittable.mode =`
|
|
1188
|
+
- An invalid `mode:` (and `Permittable.mode =` / `Permittable.error_format =` / `Permittable.check_column_types =` reject invalid values at assignment)
|
|
1189
|
+
- A field whose declared type disagrees with its column's, when `Permittable.check_column_types` is on
|
|
866
1190
|
- A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
|
|
867
1191
|
|
|
868
1192
|
</details>
|
|
@@ -872,12 +1196,16 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
872
1196
|
| Requirement | Supported |
|
|
873
1197
|
|---|---|
|
|
874
1198
|
| Ruby | >= 3.2 |
|
|
875
|
-
| Rails / ActiveSupport | >=
|
|
1199
|
+
| Rails / ActiveSupport | >= 6.1, < 9 |
|
|
876
1200
|
| Required dependency | `activesupport` only |
|
|
877
1201
|
| Optional | `actionpack` (rendering, `before_action`), `activerecord` (drift guard) |
|
|
878
1202
|
|
|
879
1203
|
`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.
|
|
880
1204
|
|
|
1205
|
+
Both claims are **tested rather than asserted**. CI runs the full suite against every ActiveSupport line in the range — 6.1, 7.0, 7.1, 7.2, 8.0, 8.1 — across the supported Rubies (see [`gemfiles/`](gemfiles/README.md)), and a separate job installs the built gem with *nothing but activesupport* and exercises every controller-free surface, so "activesupport is the only runtime dependency" cannot quietly stop being true.
|
|
1206
|
+
|
|
1207
|
+
The 6.1 floor isn't arbitrary. `class_attribute ... default:`, which declares the contract registry, arrived in Rails 5.2 — on 5.0 and 5.1 a contract cannot be declared at all — and 5.2/6.0 predate Ruby 3.x support, which this gem's own Ruby floor requires.
|
|
1208
|
+
|
|
881
1209
|
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.
|
|
882
1210
|
|
|
883
1211
|
## Development
|