permittable 0.9.0 → 0.11.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 +41 -0
- data/README.md +79 -18
- data/lib/permittable/audit.rb +231 -20
- data/lib/permittable/authored_values.rb +24 -8
- data/lib/permittable/generator.rb +69 -14
- data/lib/permittable/json_schema.rb +28 -4
- data/lib/permittable/open_api.rb +438 -80
- data/lib/permittable/tasks/generate.rake +9 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +214 -49
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be27cc7405432910133e85a3fb187c0147f14a7a28d98f960c58bc7190fc0271
|
|
4
|
+
data.tar.gz: e70282fd762f9a4c57d1b43e0c01b4306193997930b57015efc28b0af576564a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 61ec3dadad1f5fbc42c1c0403d228dff1d51c9ccaff13b8d455fb5d1ef795a01ddf7b64c0fa5e6dac76984bb22ad208b38ee50a1e6593326eb40704dbeecc8a6
|
|
7
|
+
data.tar.gz: '0443649c00422967f30d26d3e5982735433756c2aa1a669e26c10eaeaec707d48c2b4f2992805c3544e0bf68d502e0d9879776e78da2d497a7beacb18949dcb9'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.11.0 (2026-10-05)
|
|
4
|
+
<!-- title: an audit ignore list, route-order-stable operationIds, and routes read the way Rails dispatches them -->
|
|
5
|
+
|
|
6
|
+
One new setting and six fixes, closing every open issue. `Permittable.audit_ignore` lets `permittable:audit[strict]` pass in an app that uses ActiveStorage or a catch-all 404 route, and controllers from engines and gems are now listed apart from the app's own rather than counted against it. The OpenAPI export and the audit now read the route set the way Rails dispatches it: an earlier unconstrained route wins its verb and path over a later one (including a later `via: :all`), optional segments expand in Rails' own match order with constraints taken into account, and two routes no longer export paths that differ only in variable names. Colliding operationIds are ranked by the operation itself, so reordering `routes.rb` no longer renames a generated client method. The audit stops reporting a contract on a shared base controller as stale, and the wrapper-key exemption from 0.10.0 now holds whatever order ParamsWrapper and Permittable are included in, and no longer probes the request when no contract uses its answer. The compatibility matrix now runs the Railtie specs on every supported Rails line.
|
|
7
|
+
|
|
8
|
+
Minor rather than patch: there is new public API, and three changes are visible from outside. Controllers whose source is outside `Rails.root` no longer count towards `[strict]` by default (set `Permittable.audit_ignore_outside_root = false` to keep counting them). Some existing operationIds change once on upgrade: see the operationId entry under **Changed** for which. And a route that an earlier unconstrained route already answers for the same verb and path is no longer described, so a contract left on its action is now reported as stale.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`Permittable.audit_ignore`: leave controllers and actions out of `permittable:audit` and `permittable:generate`.** Two endpoints failed `[strict]` in nearly every app without being a risk the app could fix: ActiveStorage's direct uploads controller (a POST with no contract, which `permittable:generate` also drafted), and a catch-all 404 route, whose `via: :all` reads as accepting a body on POST, PUT and PATCH. `Permittable.audit_ignore = ["active_storage/direct_uploads", "application#not_found"]` takes controller paths as Rails routes them and `controller#action` strings, validated on assignment: a class name raises with the path it meant, and a Class or a Regexp is refused. Controllers whose source is outside `Rails.root` (engines and gems: ActiveStorage, ActionMailbox, Devise, Rails' own `rails/*`) are ignored by default, including gems installed under the app's own `vendor/bundle`; `Permittable.audit_ignore_outside_root = false` audits them too. Ignored endpoints leave the counts and never fail `[strict]`, but the report still lists them in a section per reason, along with any ignore entry that matches no routed action. The generator's sweep skips ignored controllers and leaves an ignored action out of its draft; naming a controller still drafts it whole. (#69)
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **Colliding operationIds are ranked by the operation itself, not by load and route order.** The rank is routed first, then controller path and action, then the shallowest path, then GET, POST, PATCH, PUT, DELETE. Reordering `routes.rb`, or a controller loading earlier, therefore never renames a generated client method. **This can change some existing operationIds once on upgrade**: two controllers that fold to one id (`admin/users` and `admin_users`) whose load order disagreed with string order, one action reached by separate routes at several paths or verbs (`post "admin/legacy"` before `post "legacy"`, `via: [:post, :get]`), and routes with nested optional groups. `resources` PATCH/PUT pairs, a single optional scope, and ids that never collided are unchanged. Adding or removing a colliding route can still rename the other members of its group. (#68)
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **The export and the audit described routes Rails never dispatches to.** A route that an earlier, unconstrained route already answers for the same verb and path is no longer described. A `match ..., via: :all` route keeps only the verbs no earlier route answers on that path, so `get "hooks"` → `index` drawn before it no longer produces a phantom `GET /hooks receive` audit row or hands that slot to `receive` in the export. A route hidden this way is dropped, and a contract left on its action is reported as stale. A constrained route (a segment requirement, a subdomain or host, a constraint object or lambda) never hides the routes behind it, because Rails passes on to them every request the constraint turns away: the audit is a gate, and it errs towards listing a route rather than hiding an unguarded one. Only identical path templates are compared. (#68)
|
|
18
|
+
- **Optional route segments were expanded by string, not as Rails matches them, and two routes could export paths that differ only in variable names.** `scope "(:locale)"` put its root at `/{locale}` and its `get ":slug"` at `/{slug}`, two templates OpenAPI forbids side by side; and since Rails sends `GET /foo` to the root, the second never reached `pages#show` unless a constraint on `:locale` turned the request away. Such paths are now one path, spelled the way the route Rails tries first spells it: a later route under another verb is placed there with its own spelling in `x-permittable-path`, and one under the same verb is listed in the earlier operation's `x-permittable-shadows` instead of being emitted (its operation lands in `x-permittable-controllers` if it has no other path). Expansion now follows Rails' own match order, constraints included: `get "x(/:a)(/new)"` no longer documents `/x/new`, which Rails reads as `a = "new"` (unless `:a`'s constraint rejects it), and `x(/:a)(/:b)` with `a: /\d+/`, where Rails binds `/x/foo` to `:b`, lists `/x/{b}` on `/x/{a}` rather than dropping it silently. The route is read from the parsed Journey tree rather than its printed string, once per route rather than once per verb; a Journey-shaped stand-in with a String spec now honours escaped parentheses and raises on unbalanced ones instead of truncating the path. (#67)
|
|
19
|
+
- **`Audit.stale` reported a contract inherited from a shared base controller as stale.** `permittable_contracts` is a `class_attribute`, so a base controller with no routes of its own, and every subclass routed to a different slice of its actions, was listed under "Contracts declared for actions no route reaches". A contract's action is now stale only when no controller that carries the contract (the declaring class or any of its subclasses) reaches it, and it is listed once, under the class that declared it. The audit summary also agrees in number when a count is one ("1 of those accepts a request body", "1 covered action declares no model:, so no schema-drift guard runs for it"). (#49)
|
|
20
|
+
- **The wrapper-key exemption switched off when ParamsWrapper came before Permittable in the ancestor chain.** It recorded ParamsWrapper's key in `process_action`, relying on running before ParamsWrapper wrapped the body. A controller that includes `ActionController::ParamsWrapper` after Permittable, such as a Metal subclass, ran ParamsWrapper first, and every wrapped JSON request to a rootless `unknown: :error` contract was a 422 again. The key is now read in `process`, which every `process_action` runs inside, so module order no longer matters. The probe also no longer touches the request unless some contract on the controller acts on its answer: a rootless one that checks unknown keys or declares a field of the wrapper's name. (#70)
|
|
21
|
+
|
|
22
|
+
### Test suite
|
|
23
|
+
- **The compatibility matrix never ran the Railtie specs.** None of the `gemfiles/activesupport_*.gemfile` variants bundled `railties`, so `spec/railtie_spec.rb` skipped itself in every job but the root `Gemfile`'s: the `filter_parameters` wiring, its reach into ActiveRecord's `filter_attributes`, the late-bound registry and rake-task loading went untested on every supported Rails line but the newest. Each gemfile now pins `railties` to its own line, and the root `Gemfile`'s floor moves to 6.1 alongside `actionpack`/`activerecord`. Running the spec on old Rails surfaced two problems in its probe app, none in the gem: the probe booted without loading `logger` first (a NameError on activesupport 6.1), and asserted `precompile_filter_parameters`, which arrived in Rails 7.1. The gem itself needed no change on any version. (#50)
|
|
24
|
+
- **The conformance spec read exported patterns as Ruby regexps, so `^` and `$` anchored a line.** `spec/support/tiny_json_schema.rb` compiled each `pattern` with `Regexp.new`, but the document holds ECMA-262, where `^` and `$` without the m flag anchor the whole input. `format: /\Aabc\z/` exports `^abc$`; the server and every real validator reject `"abc\n"` against it, and the instrument accepted it — a spurious "schema looser than server" divergence in the one direction the spec says cannot happen, which also hid a real anchor regression. The instrument now undoes the anchor translation before compiling (`^` → `\A`, `$` → `\z`), tokenizing escape pairs and character classes as units the way `EcmaPattern` does, and `spec/ecma_pattern_spec.rb` checks it against node over every exported pattern. No gem behaviour changes. (#48)
|
|
25
|
+
|
|
26
|
+
## 0.10.0 (2026-09-30)
|
|
27
|
+
<!-- title: canonical numeric strings, the app's own CSRF param, and defaults that agree with requests -->
|
|
28
|
+
|
|
29
|
+
Ten fixes and no new surface. Three change what an existing contract does. A numeric string must now be spelled canonically: `Integer()`, `Float()` and `BigDecimal()` all read underscore separators and surrounding whitespace, so `"1_8"` was eighteen and `" 99 "` was ninety-nine, and both are now `invalid_type`. An array `default:` now runs its sub-fields' `transform:` when the contract loads, so a request that omits the field and one that sends the default's value hand the action the same thing. And an app that renamed its CSRF parameter with `config.action_controller.request_forgery_protection_token` stops failing every form POST under `unknown: :error`. Alongside them: a `sensitive:` field no longer publishes its own `default:`/`example:` in the exported schema, a `message:` shared through `use` can no longer be rewritten from one contract into another, `permittable:generate` stops drafting an enum accessor the model does not answer to and stops hiding a real error behind an empty draft, and three pieces of process-wide state — the error-response schemas, the scalar JSON Schema entries and the `sensitive:` sink list — can no longer be corrupted by a caller or a concurrent class load.
|
|
30
|
+
|
|
31
|
+
Minor rather than patch: the API is untouched, but two fixes are visible from outside. A client that pads a number with whitespace or underscores now gets a 422 where it used to get the number, and an array `default:` whose sub-fields declare `transform:` is now handed out transformed — that `transform:` is app code, and it now runs when the contract loads. Read the first and third entries under **Fixed** before upgrading if either applies to your contracts.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **`:integer`, `:float` and `:decimal` read `"1_8"` as eighteen and `" 99 "` as ninety-nine.** The casts delegate to `Integer()`, `Float()` and `BigDecimal()`, which all accept underscore digit separators and surrounding whitespace — a convenience for a number literal in Ruby source, not for a request body, and the same kind of leniency the NaN/Infinity and underflow checks already close off through other doors. A numeric string is now checked against a canonical form before anything parses it: an optional sign, digits, an optional fraction and an optional exponent — `"-12"`, `"007"`, `".5"`, `"1.5e10"`, and `"1e400"` for `:decimal` all still cast — and anything else is `invalid_type`. `normalize:` is a `:string`-only option, so there is no in-contract way to keep the old whitespace tolerance; a client that pads a number has a formatting bug upstream, which is now reported rather than repaired.
|
|
35
|
+
- **A CSRF parameter renamed with `request_forgery_protection_token` failed every form POST under `unknown: :error`.** The top-level exemption hard-coded Rails' default `authenticity_token`, so an app that renamed it got `{ param: "csrf_token", code: "unknown" }` on every ordinary form submission — exactly the failure the exemption exists to prevent, spelled with the app's own key. The controller's configured token name is now read fresh on each request (it can vary per controller, and Rails may not have finished initializing when the gem loads) and exempted alongside the fixed keys. A plain params duck and a standalone `Contract` have no such setting and are unchanged, and so is monitor mode's raw pass-through.
|
|
36
|
+
- **An array `default:` skipped its sub-fields' `transform:`, so an omitted field and an explicitly-sent identical value diverged.** An authored array default is walked by the same code a request goes through, but that walk suppressed `transform:` on *every* field it visited rather than only the array's own. With `array :line_items, default: [{ "price" => "10.00" }] do optional :price, :decimal, transform: ->(v) { v * 100 } end`, a request omitting `line_items` got `price: 10` and one sending `[{ "price" => "10.00" }]` got `price: 1000`, and the exported OpenAPI `default` documented a value the server never produced. A sub-field's own `transform:` now runs over the default when the contract loads, so the stored value is what an equivalent request yields. The array field's **own** `transform:` still never runs over its default, exactly as `default:` documents, and the `with_default` matcher reads its expected array through the same walk, so it stays in agreement.
|
|
37
|
+
- **A field's `message:` was never deep-frozen, so two contracts sharing it through `use` could corrupt each other.** `default:`, `example:` and `in:` are deep-copied and deep-frozen on declaration; `message:` was frozen only at the top level in its Hash form and not at all as a String. `Permittable.fields` splices a group's field hashes into each contract by reference, so `msg << " NOW"` on one contract's violation message rewrote the message every other contract using that group would render, for the life of the process. Both forms now go through the same copy-then-freeze as the other authored values.
|
|
38
|
+
- **A `sensitive:` field's `default:`/`example:` were exported in the clear.** The field was marked `writeOnly` and registered for log redaction, and then the exported JSON Schema and OpenAPI document — the one output meant to leave the app, for client generators or a public docs endpoint — carried the real value under `default` and `examples`. Both keys are now omitted for a sensitive field, a child inheriting the cascade included; `description` is still exported, being authored prose rather than the value.
|
|
39
|
+
- **`permittable:generate` drafted `Model.statuses.keys` for an accessor the model does not have.** The generator only checked that the pluralised enum name was a valid identifier, so a renamed or excluded accessor produced a draft that raised `NoMethodError` the moment it loaded — while the schema-drift guard's own error messages already made the safer choice. The draft now also checks `respond_to?` and otherwise spells the list as `Model.defined_enums["status"].keys`, which is always callable.
|
|
40
|
+
- **`permittable:generate` swallowed every error while reading a model's columns and drafted an empty contract instead.** The rescue was a blanket `StandardError`, so a broken custom type adapter, or any plain bug, quietly produced a degraded draft with nothing to say why. It is now scoped to `ActiveRecord::ActiveRecordError`, the same rule `ColumnGuard.schema_reachable?` follows: a genuinely unreachable schema (no database yet, table not migrated) still degrades to "no columns", and anything else propagates.
|
|
41
|
+
- **`OpenAPI.document` handed out its error-response schemas by reference, so a caller editing its own document rewrote them for every document generated afterward.** `ERROR_SCHEMA` and `PROBLEM_SCHEMA` are frozen, but Ruby's `freeze` is shallow: assigning into a nested level of a generated document raised nothing and permanently changed the constant. Each document now gets a deep copy. The JSON Schema exporter had the same shape of exposure — a `SCALAR_SCHEMAS` entry was shallow-copied, so every `:decimal` schema shared one live `type` Array with the constant — and now deep-copies too. Nothing inside the gem mutated either in place; this closes the door on a caller doing so.
|
|
42
|
+
- **`sensitive:` sink callbacks were iterated without the lock they are appended under.** `Permittable.on_sensitive_parameter` appends to the sink list under `@registry_mutex`; a contract declaring a `sensitive:` field iterated that same list without it. The GVL hides this on MRI, but on JRuby or TruffleRuby a contract class-loading while a sink is installed is a concurrent mutation during iteration — a raise, or a sink call silently skipped, which is a parameter name that never reaches `config.filter_parameters`. The list is now snapshotted under the mutex and the callbacks run against the snapshot, outside it, so a sink that calls back into the gem cannot deadlock.
|
|
43
|
+
|
|
3
44
|
## 0.9.0 (2026-09-27)
|
|
4
45
|
<!-- title: params.expect drafting, audit coverage, field groups, and a rewritten pattern translator -->
|
|
5
46
|
|
data/README.md
CHANGED
|
@@ -195,7 +195,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
|
|
|
195
195
|
- **[Adopting on a live API](#adopting-on-a-live-api)**
|
|
196
196
|
- [Monitor mode](#monitor-mode-roll-out-without-rejecting)
|
|
197
197
|
- [Generating draft contracts](#generating-draft-contracts-permittablegenerate)
|
|
198
|
-
- [Auditing coverage](#auditing-coverage-permittableaudit)
|
|
198
|
+
- [Auditing coverage](#auditing-coverage-permittableaudit) · [Ignoring controllers and actions](#ignoring-controllers-and-actions)
|
|
199
199
|
- **[Beyond the controller](#beyond-the-controller)**
|
|
200
200
|
- [Testing contracts](#testing-contracts-rspec-matchers)
|
|
201
201
|
- [Standalone contracts](#standalone-contracts-no-controller)
|
|
@@ -297,7 +297,7 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
297
297
|
| `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
|
|
298
298
|
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
|
|
299
299
|
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
|
|
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. 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 |
|
|
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
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 |
|
|
302
302
|
| `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
|
|
303
303
|
| `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
|
|
@@ -359,9 +359,9 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
359
359
|
| Type | Accepts | Rejects (`invalid_type`) |
|
|
360
360
|
|---|---|---|
|
|
361
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`); base-10 numeric strings | `"4.5"`, `"abc"`, `4.5`, NaN/Infinity |
|
|
363
|
-
| `:float` | `Numeric`;
|
|
364
|
-
| `:decimal` | `Numeric
|
|
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 "` |
|
|
365
365
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
366
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"`) |
|
|
367
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"`) |
|
|
@@ -373,6 +373,8 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
373
373
|
|
|
374
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.
|
|
375
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
|
+
|
|
376
378
|
Two more behaviours worth committing to memory:
|
|
377
379
|
|
|
378
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.
|
|
@@ -572,7 +574,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
|
|
|
572
574
|
|
|
573
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.
|
|
574
576
|
|
|
575
|
-
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.
|
|
576
578
|
|
|
577
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.
|
|
578
580
|
|
|
@@ -830,6 +832,8 @@ bin/rails permittable:generate # every controller without a
|
|
|
830
832
|
bin/rails "permittable:generate[UsersController]" # one controller, even if covered
|
|
831
833
|
```
|
|
832
834
|
|
|
835
|
+
The sweep skips what the audit [ignores](#ignoring-controllers-and-actions): controllers whose source is outside `Rails.root` (so ActiveStorage's direct uploads controller is not drafted), and anything in `Permittable.audit_ignore`. A `controller#action` entry leaves only that action out of the controller's draft. Naming a controller drafts it regardless, the same way it drafts a covered one.
|
|
836
|
+
|
|
833
837
|
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:
|
|
834
838
|
|
|
835
839
|
```ruby
|
|
@@ -919,9 +923,9 @@ Three things it tells you that nothing else does:
|
|
|
919
923
|
|
|
920
924
|
- **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*.
|
|
921
925
|
- **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.
|
|
922
|
-
- **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.
|
|
926
|
+
- **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. A contract on a shared base controller is stale only when no controller that inherits it reaches the action, and it is listed once, under the class that declares it.
|
|
923
927
|
|
|
924
|
-
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.
|
|
928
|
+
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. Rails sends a request to the first route that matches its path and verb, and the audit lists what Rails dispatches. With `get "hooks", to: "webhooks#index"` before `match "hooks", to: "webhooks#receive", via: :all`, `GET /hooks` is listed under `index` only, and `receive` gets the other four verbs. Turn the two around and the `get` route is never reached, so it is not listed, and a contract on `index` shows as stale. The same rule removes a route declared twice, and the OpenAPI export applies it too. Only an unconstrained route hides the routes behind it, and only on the identical path. A route with a constraint can pass a request on: a segment requirement (`id: /\d+/`), a subdomain or host, or a constraint object or lambda. So the routes after it stay listed, even when the constraint happens to admit every request. With `constraints(AdminConstraint) { post "settings", ... }` ahead of `post "settings", to: "settings#update"`, every non-admin `POST` reaches `settings#update`, so its row stays and `[strict]` still counts it. The audit errs towards an extra row rather than a hidden route. Paths that differ at all, even only in a variable's name (`users/:id` and `users/:slug`), are both listed. `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.
|
|
925
929
|
|
|
926
930
|
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:
|
|
927
931
|
|
|
@@ -932,11 +936,60 @@ match "*path", to: ->(_env) { [404, { "content-type" => "text/plain" }, ["Not Fo
|
|
|
932
936
|
|
|
933
937
|
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.
|
|
934
938
|
|
|
935
|
-
Under `[strict]` the task aborts on these rows
|
|
939
|
+
Under `[strict]` the task aborts on these rows. Either route the catch-all GET-only, as above, or add `application#not_found` to the [ignore list](#ignoring-controllers-and-actions). 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.
|
|
936
940
|
|
|
937
941
|
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.
|
|
938
942
|
|
|
939
|
-
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.
|
|
943
|
+
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. It takes `ignore:` and `root:` too, which default to `Permittable.audit_ignore` and `Rails.root` (see below).
|
|
944
|
+
|
|
945
|
+
#### Ignoring controllers and actions
|
|
946
|
+
|
|
947
|
+
Some routed endpoints are not yours to guard. Two are common enough to fail almost every `[strict]` run: ActiveStorage's direct uploads controller (a `POST` with no contract), and the catch-all 404 above, whose `via: :all` adds `POST`, `PUT` and `PATCH` rows. List them in an initializer:
|
|
948
|
+
|
|
949
|
+
```ruby
|
|
950
|
+
# config/initializers/permittable.rb
|
|
951
|
+
Permittable.audit_ignore = [
|
|
952
|
+
"active_storage/direct_uploads", # every action of the controller
|
|
953
|
+
"application#not_found" # one action, on every verb it is routed on
|
|
954
|
+
]
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
An entry is a controller path the way Rails routes it (the controller half of `bin/rails routes`, such as `admin/users`), or `controller#action`. Entries must be strings, checked when you assign the list:
|
|
958
|
+
|
|
959
|
+
- A class name raises `ArgumentError` naming the path you meant: `"ActiveStorage::DirectUploadsController"` → did you mean `"active_storage/direct_uploads"`?
|
|
960
|
+
- A class raises too. Naming one in an initializer would autoload the controller during boot, which Zeitwerk refuses for a reloadable controller.
|
|
961
|
+
- A `Regexp` raises. A pattern would also ignore controllers added after it, and catching a new unguarded endpoint is the point of the gate.
|
|
962
|
+
|
|
963
|
+
Assign the whole list. It is frozen, so add to it with `Permittable.audit_ignore += ["webhooks#receive"]`, which is validated too.
|
|
964
|
+
|
|
965
|
+
**Controllers outside `Rails.root` are ignored by default.** These are engines and gems: ActiveStorage, ActionMailbox, Devise, and Rails' own `rails/*` controllers. An app can't keep a contract in a gem's controller. The audit locates a controller by the file that defines its class (`Module#const_source_location`). A gem installed inside the app still counts as a gem; `bundle config path vendor/bundle` does this, and so do CI caches. A controller whose source can't be located is audited. To audit gem controllers like your own:
|
|
966
|
+
|
|
967
|
+
```ruby
|
|
968
|
+
Permittable.audit_ignore_outside_root = false
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
That default already covers ActiveStorage. Naming it, as above, keeps it ignored if you turn the default off. To put a gem's endpoint under the gate, subclass its controller in `app/controllers` (for Devise, `devise_for :users, controllers: { sessions: "users/sessions" }`). The subclass is the app's, so it is audited.
|
|
972
|
+
|
|
973
|
+
Ignored endpoints leave the counts and never fail `[strict]`, but they stay in the report. Each reason gets its own section, with one line per action and the verbs it is routed on. An entry that matches no routed action (a typo, or a route since removed) is listed as well. Otherwise a typo would ignore nothing, and nothing would say so. With the initializer above plus a mistyped `"admin/report"`, the end of the report reads:
|
|
974
|
+
|
|
975
|
+
```
|
|
976
|
+
Ignored by Permittable.audit_ignore (left out of the counts above):
|
|
977
|
+
active_storage/direct_uploads#create POST
|
|
978
|
+
application#not_found DELETE, GET, PATCH, POST, PUT
|
|
979
|
+
|
|
980
|
+
Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):
|
|
981
|
+
active_storage/blobs/redirect#show GET
|
|
982
|
+
active_storage/disk#show GET
|
|
983
|
+
active_storage/disk#update PUT
|
|
984
|
+
rails/health#show GET
|
|
985
|
+
|
|
986
|
+
Permittable.audit_ignore entries that match no routed action (a typo, or a route since removed?):
|
|
987
|
+
admin/report
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
An unmatched entry is reported, not failed: a mistyped `application#not_foud` leaves the catch-all's rows in the count, so `[strict]` fails on those, and this list says why.
|
|
991
|
+
|
|
992
|
+
`permittable:generate` honours the same list (see [generating draft contracts](#generating-draft-contracts-permittablegenerate)).
|
|
940
993
|
|
|
941
994
|
---
|
|
942
995
|
|
|
@@ -1035,7 +1088,7 @@ bin/rails permittable:openapi # JSON to stdout
|
|
|
1035
1088
|
bin/rails "permittable:openapi[openapi/api.json]" # write to a file
|
|
1036
1089
|
```
|
|
1037
1090
|
|
|
1038
|
-
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.
|
|
1091
|
+
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. Each path and verb is documented under the action Rails dispatches it to, which is the first route that matches. So an explicit `get` before a `via: :all` route on the same path keeps `GET`, and a route behind an earlier, unconstrained one with the same path and verb is not documented under that verb. A constrained route hides nothing ([the audit](#auditing-coverage-permittableaudit) explains why). When two routes then claim one path and verb, the second operation lands under `x-permittable-controllers`. `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.
|
|
1039
1092
|
|
|
1040
1093
|
Or build fragments programmatically — no Rails required:
|
|
1041
1094
|
|
|
@@ -1048,7 +1101,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
|
1048
1101
|
|
|
1049
1102
|
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.
|
|
1050
1103
|
|
|
1051
|
-
**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.
|
|
1104
|
+
**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, or whose route an earlier one shadows (below) — 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.
|
|
1105
|
+
|
|
1106
|
+
**Paths are the URLs Rails dispatches.** An optional segment expands into each path it stands for, read the way Rails matches it: present before absent, left to right, constraints included. So `get "x(/:a)(/new)"` documents no `/x/new`, because Rails reads that URL as `a = "new"`, unless a constraint on `:a` rejects `"new"`. OpenAPI forbids two paths that differ only in their variable names, and two routes can produce them: under `scope "(:locale)"` a root is `/{locale}` and a `get ":slug"` is `/{slug}`. Such paths are one path, spelled as the route Rails tries first spells it. A later route under another verb is placed there too, and carries its own spelling in `x-permittable-path`. One under the same verb is shadowed: Rails sends that URL to the earlier route, and reaches the later one only when the earlier route's constraints turn the request away. It is listed in the earlier operation's `x-permittable-shadows` instead of being emitted as a second path, and its operation lands in `x-permittable-controllers` if it has no other path. A variant of one route that a constraint keeps reachable is listed the same way: `x(/:a)(/:b)` with `a: /\d+/` sends `/x/foo` to `:b`, so `/x/{a}` lists `/x/{b}`.
|
|
1052
1107
|
|
|
1053
1108
|
**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:
|
|
1054
1109
|
|
|
@@ -1057,10 +1112,14 @@ Every operation references shared components for the [error envelope](#violation
|
|
|
1057
1112
|
| 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 |
|
|
1058
1113
|
| 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 |
|
|
1059
1114
|
| 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` |
|
|
1060
|
-
| 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
|
|
1061
|
-
| Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on
|
|
1115
|
+
| 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 shallower path, `posts_create_2` on the other |
|
|
1116
|
+
| Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on `admin/users`, whose controller path sorts first, and `admin_users_index_2` on `admin_users` |
|
|
1117
|
+
|
|
1118
|
+
One place keeps the plain id. The exporter ranks the places by what they are, never by the order it finds them in. A routed operation comes before one under `x-permittable-controllers`, which takes part because it is in the same document. Then operations are ranked by controller path, then action, compared as strings, whatever their verbs. Within one operation, the shallowest path comes first (the fewest segments, then alphabetical order), then the verb: GET, POST, PATCH, PUT, DELETE. So PATCH comes before PUT whichever the route lists first, and `match via: [:put, :patch]` and `resources` name the PATCH method the same way. 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 in rank order, from `_2`. So a second PATCH is `users_update_2`, not `users_update_patch`, and a second POST is `posts_create_2`. A route declared twice is placed once, and a controller passed twice is documented once, so neither collides with itself.
|
|
1119
|
+
|
|
1120
|
+
**The stability rules.** An id that only one operation would carry never changes, even when a suffix elsewhere would spell it; that suffix is numbered instead. Reordering `routes.rb`, or a controller that loads earlier, renames nothing, because the rank never reads that order. (The exception is two routes that answer the same path and verb. Their order decides which one Rails dispatches to, and the export follows Rails. When the first of them is constrained, both are kept, controller order picks which operation takes the slot, and the other lands under `x-permittable-controllers`.)
|
|
1062
1121
|
|
|
1063
|
-
|
|
1122
|
+
**What can still rename a generated method: a change to a colliding group.** No scheme can keep every id when a new collider arrives, because one of them must take a suffix. A new route, action or controller whose id collides gets a suffix. If it ranks first, it takes the plain id from the place that had it instead. Every numbered place ranked after it moves along by one. Removing or renaming a collider moves the others the same way. Only the members of the colliding group can be renamed, never an id that stands alone. If a client must never change, remove the collision: give the operations distinct controller paths or action names.
|
|
1064
1123
|
|
|
1065
1124
|
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.
|
|
1066
1125
|
|
|
@@ -1104,7 +1163,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1104
1163
|
| nested block / `array` | `object` + `properties` / `array` + `items` |
|
|
1105
1164
|
| `unknown: :error` | `additionalProperties: false`, at every nesting level |
|
|
1106
1165
|
| `root:` | the wrapping object, itself required |
|
|
1107
|
-
| `sensitive: true` | `writeOnly: true` (never echoed in responses) |
|
|
1166
|
+
| `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published |
|
|
1108
1167
|
|
|
1109
1168
|
</details>
|
|
1110
1169
|
|
|
@@ -1142,12 +1201,14 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1142
1201
|
| `Permittable.error_format` / `=` | `:envelope` (default) or `:problem` — see [RFC 9457 problem+json](#rfc-9457-problemjson) |
|
|
1143
1202
|
| `Permittable.problem_base_uri` / `=` | Base URI for problem `type` members |
|
|
1144
1203
|
| `Permittable.check_column_types` / `=` | Opt in to the [type half of the drift guard](#checking-types-too-opt-in) (default `false`) |
|
|
1204
|
+
| `Permittable.audit_ignore` / `=` | Controller paths and `controller#action` strings the audit and the generator [leave out](#ignoring-controllers-and-actions) (default `[]`) |
|
|
1205
|
+
| `Permittable.audit_ignore_outside_root` / `=` | Also leave out controllers whose source is outside `Rails.root` (default `true`) |
|
|
1145
1206
|
| `Permittable.fields(&block)` | A reusable [field group](#reusing-fields-permittablefields-and-use) — splice it into a contract with `use` |
|
|
1146
1207
|
| `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
|
|
1147
1208
|
| `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
|
|
1148
1209
|
| `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
|
|
1149
|
-
| `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
|
|
1150
|
-
| `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
|
|
1210
|
+
| `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`, `.targets`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
|
|
1211
|
+
| `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`, `.ignore_reason`, `.unmatched_ignores`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
|
|
1151
1212
|
| `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`, `#fields`) |
|
|
1152
1213
|
| `Permittable::FieldGroup` | A [reusable field list](#reusing-fields-permittablefields-and-use) (`#fields`, `#names`) — built by `Permittable.fields` |
|
|
1153
1214
|
| `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) |
|