permittable 0.6.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddca88b2da7672d0995e338ab901188c4ba019f4d5928d7aaf0a5f73a7f8ba9c
4
- data.tar.gz: 1a66ef88adc7ab843798f25c7a5e3444a3e550795d1353139d52b65e05ac5005
3
+ metadata.gz: 9eb3f4de06deb2a19c6a24b870e6f4d718350b67d733feb6d3ba423f27b6cdc9
4
+ data.tar.gz: a1a231373f6d9bab90e1e14ff372c3ab22c163783f3beafb9c4e23e0e46b3d18
5
5
  SHA512:
6
- metadata.gz: 7288c663fd8a2d740ca1c1e7fe9ad61d50854fa47e0e9c4a75bb0922bd119d7c4ca9fe99b020b6ea03edfb456d0fe88322161d53cc1bdd71afb6b88f6cf6ccda
7
- data.tar.gz: a9ecd5c620fac068991a0401d76f339163eb20b6bed73ab83a33f5c3895f8bfb82479a86ab9d6385d85321732ce8ebc6c332819b32174bc8d0494c6021d1623f
6
+ metadata.gz: f69d441339746c659b6dc64589f77671f22fc13c701116389bbd916ce533ea80a737387605314679b47363c0de784f805aed75c83d7b835b8857aeac305167d2
7
+ data.tar.gz: 9c64b6950b2149ce8aed60113c07bff435119c33090db0883a50464e31609fa52ceefe848ae52f0e1a3ea34a312669a7022e771f648f3e20ad12ef96fd4c9023
data/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.8.0 (2026-09-19)
4
+ <!-- title: a no-op sensitive: cascade, an invalid OpenAPI export, and megabyte-scale rejections -->
5
+
6
+ Thirteen fixes, every one of them the gem doing its job wrongly rather than not at all. `sensitive: true` on a nested block or array was a complete no-op, printing in the clear the very values it promised to redact. Every exported OpenAPI document containing a member route was invalid — the committed golden fixture included — because a templated `{id}` was never declared as a parameter. `:datetime` raised `NameError` in any host that had not loaded ActiveSupport's time extensions, and normalising a `Time` to UTC rewrote the caller's own object. `:decimal` accepted the literal string `"NaN"` where `:float` rejected it. A rejected request instrumented itself once per read, double-counting in every dashboard. And one request could write a megabyte of log line, or spend nine seconds and 9.5 MB rejecting an array its own `length:` bound had already refused.
7
+
8
+ Minor rather than patch: nothing changes shape and the API is untouched, but three fixes are visible from outside. `:float` and `:decimal` now reject values they used to accept, a malformed `root:` reports `invalid_type` where it reported `missing`, and the `sensitive:` cascade widens redaction app-wide. Read **Upgrading** before deploying.
9
+
10
+ ### Fixed
11
+ - **A wildcard route exported an invalid OpenAPI path.** `OpenAPI.rails_routes` templated the `:id` form of a Rails path parameter but not the `*rest` wildcard, so `get "files/*path"` produced the path `/files/*path` — which is not a valid OpenAPI path template, and makes the whole exported document fail validation. Both forms are now templated: `/files/{path}`, including mixed routes like `/files/:bucket/*path` → `/files/{bucket}/{path}`.
12
+ - **A templated path variable was never declared as a parameter.** OpenAPI 3.1 requires every `{variable}` in a path template to have a matching path parameter, and the exporter emitted none — so **every document containing a member route was invalid**, the committed golden fixture included. Each operation now carries one `{ in: "path", required: true }` parameter per variable in the path it was placed at, typed `string`: a route set does not say what an `:id` is, and the exporter documents what arrives rather than guessing. A new spec asserts the invariant over the whole document, so an operation added later cannot reintroduce it.
13
+ - **A route answering several verbs was documented for only one of them.** `rails_routes` kept `verb.split("|").first`, so the `PATCH|PUT` pair `resources` generates — and any `match via: [:patch, :put]` — exported the PATCH operation and silently dropped PUT. Every verb a route answers now gets its own descriptor.
14
+ - **The error-response schema had drifted from what the server renders.** `ERROR_SCHEMA` is the one hand-written part of the export, and it had fallen behind twice over: a violation on a field with `message:` (or with app I18n copy) carries a third key the schema didn't mention, so clients generating types from it dropped the human-readable copy; and the `code` enumeration never gained `depth`, which a `:json` field's `max_depth:` bound emits. `message` is now documented as an optional property — `required` stays `param` + `code`, since a violation without one keeps the bare shape — and `depth` is listed. A new spec renders a real violation and holds the schema to the envelope, so this half of the "docs cannot drift" claim is now guarded like the request-body half.
15
+ - **Two controllers on one path and verb silently overwrote each other.** A document cannot carry two operations in one slot, and the second claim replaced the first with no indication anything had been lost. The loser now lands in `x-permittable-controllers`, which is where the exporter already puts an operation it cannot place.
16
+ - **Every `:datetime` cast raised `NameError` in a host that had not loaded ActiveSupport's time extensions.** A standalone `Permittable::Contract` — validating a webhook payload or a job argument — got `uninitialized constant ActiveSupport::TimeWithZone` instead of a validated param, because activesupport does not load that class by default and the gem never asked for it. A Rails app gets it via `active_support/time` at boot, which is why the spec suite (`require "active_record"`) masked it, the same shape as the 0.5.1 nested-hash bug. Fixed by requiring `active_support/core_ext/time/calculations`, which loads `TimeWithZone` **and** the `Time` extensions it needs: the class alone is not self-sufficient, and converting a real zoned time calls `Time#sec_fraction`, so requiring only `time_with_zone` would have traded `NameError` for `NoMethodError` on activesupport 8.1. The bare-subprocess spec now casts every scalar type, including a real `TimeWithZone`, in a process with no Rails.
17
+ - **`:float` and `:decimal` accepted numbers the type cannot faithfully hold, including ones a client controls.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — the first overflows, the second loses the entire value — and both were accepted silently, leaving a value no numeric column can store. `Float::INFINITY` and `Float::NAN` objects passed straight through for both types. Worst of the set: **`BigDecimal("NaN")` and `BigDecimal("Infinity")` succeed where `Float()` raises**, so a client could send the literal string `"NaN"` for a `:decimal` price and have it stored — and `:float` rejected exactly those strings, so the two types disagreed, which is what marks the behaviour as accidental rather than designed. Non-finite results are now `invalid_type` for both types.
18
+ A genuine zero is unaffected however it is spelled — `"0"`, `"0.0"`, `"0.0000"` and `"0e10"` all still cast to `0.0`. Underflow is only visible against the source text (the result is an ordinary `0.0`), so a zero result is rejected only when the string named a nonzero **significand**; the exponent's digits say nothing about the value, which is why `"0e10"` is fine. `:decimal` keeps accepting the large exponents `BigDecimal` genuinely represents (`"1e400"` → `0.1e401`), since it has no exponent limit to overflow.
19
+ - **A `root:` key sent with the wrong shape reported `missing`, which sent clients looking in the wrong place.** `{"user": "bob"}` against a `root: :user` contract answered `{ param: "user", code: "missing" }` — for a key the client had just sent. An absent root and a malformed one are different client mistakes, and now read differently: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}` — the gem's own definition of absence, so an empty string still counts), `invalid_type` when it was sent as something other than an object. Both remain **400**, since either way the envelope itself is malformed, so nothing changes at the HTTP level; only the diagnostic gets accurate.
20
+ - **A rejected request instrumented `invalid_parameters.permittable` more than once, double-counting itself in every dashboard.** `permitted_params` is documented as memoized per action, but it only memoized *successes* — on a violation it raised without storing anything, so a second read revalidated from scratch and fired the event again. Any action that reads the params twice hit this, and `permittable_violations` followed by `permitted_params` — the pattern the monitor-mode docs suggest for "would this request fail?" — hit it every time. The memo now remembers the **outcome**: a rejection is stored and re-raised (the same exception object, not an equal-looking new one), so a contract runs, and instruments, exactly once per action per request. `ArgumentError` is deliberately still raised fresh every time and never memoized — a contract that doesn't cover the action is a bug to fix, not a verdict on the request.
21
+ - **One request could write a megabyte of log line, or hand a megabyte of exception message to every error tracker.** The `unknown: :log` warn line joined **every** undeclared key, and the violation summary behind `InvalidParameters#message` (and the monitor-mode warn line) joined **every** violation. A request carrying 50,000 undeclared keys against `unknown: :log` produced a single **1 MB** `logger.warn`; the same request against `unknown: :error` produced a 1 MB exception message. `unknown: :log` is the natural mode for watching what a client really sends during a rollout, so this was on a normal path rather than an exotic corner.
22
+ Both are **prose, written for a person**: they now list at most ten names, each truncated past 120 characters, and count the rest (`…, and 49990 more`), taking that 1 MB line to 261 bytes. Truncating each name matters as much as capping the count — one 1 MB key name alone produced the same 1 MB line. The **machine-readable channels are untouched and complete** — `InvalidParameters#details` still names every offender, and so does the `invalid_parameters.permittable` instrumentation payload — because nothing should silently drop data a consumer might be reading. The only visible change is the `message` string when there are more than ten violations, or an offender's path is longer than 120 characters — neither of which an ordinary contract reaches.
23
+ One trade-off worth stating: under `unknown: :log` nothing else records an undeclared key, so beyond the tenth only the count survives. Where every name matters, `unknown: :error` in monitor mode records all of them in `details` and in the instrumentation payload without rejecting the request. The 422 body under `unknown: :error` is still proportional to the number of violations, because `details` is deliberately complete.
24
+ - **An array outside its `length:` bound was still fully examined, so an oversized payload cost far more to reject than to accept.** `length:` recorded its violation and then cast, checked and reported on every element anyway. A payload of 200,000 non-string elements against `array :tags, of: :string, length: 0..10` produced **200,001 violations and a ~9.5 MB error body after ~9.2 seconds of CPU** — for a request already refused by its first check, and against the very bound a developer declares to prevent exactly that. `length:` is now a bound rather than a report: an array outside it returns immediately, so the same payload costs **one violation, ~40 bytes and ~57 ms** of contract work (the rest of the wall time is the `HashWithIndifferentAccess` conversion of the payload, which happens before any field is examined). A consequence worth knowing: `validate:` and `transform:` are no longer handed an array the contract has already rejected, matching the rule `transform:` already followed for element violations. Arrays within their bounds, and arrays with no `length:` declared, behave exactly as before — note in particular that there is still **no default cap**, so an array with no `length:` remains unbounded and every element of it is cast and checked. `benchmark/oversized_array.rb` re-runs the measurement.
25
+ An authored `default:`/`example:` on an array is now also checked against that array's own `length:` at class load, instead of loading and handing the action an out-of-bounds default.
26
+ - **`sensitive: true` on a nested block or array was a complete no-op, and logged the values it promised to redact.** Rails' parameter filtering walks into hashes and arrays itself and asks a proc filter about the **leaf values only**, handing it the leaf's own key and never the path that led there — so registering only the container's name redacted nothing: the filter descended and asked about `"card_number"`, which the container's name does not match. A contract declaring `optional :payment, sensitive: true do required :card_number, :string end` printed the card number in the clear. `sensitive:` now **cascades** to every field inside a nested or array container, at any depth, and a spec proves it through `ActiveSupport::ParameterFilter` rather than only asserting on the registry. The cascade is resolved onto the field data at class load, so every reader of a contract agrees with the redaction: the exported JSON Schema marks a cascaded child `writeOnly`, and the RSpec matcher's `.sensitive` chain passes for it.
27
+ - **`permittable:generate` read commented-out code as if it ran.** A controller keeping a `# params.require(:admin).permit(:superuser)` line for reference had `:admin` drafted as the contract's `root:` and `:superuser` drafted as a permitted field — a wrong suggestion, and a security-flavoured one, from a line that does not execute. The same applied to `=begin`/`=end` blocks and to trailing comments on live lines. Comments are now removed before scanning, using `Ripper` (stdlib, no new dependency) rather than a regexp, because `#` is only sometimes a comment: a permit call inside `#{'#{...}'}` interpolation **is** live code and is still read, and string **content** is deliberately kept because `permit("name")` is a supported spelling whose keys live in string tokens. A file `Ripper` cannot lex falls back to the raw source, so a syntactically odd controller scans exactly as it did before rather than not at all.
28
+
29
+ ### Added
30
+ - **`sensitive: false` opts a sub-field out of an inherited cascade.** Matching is a case-insensitive **substring** match, so cascading a generic name like `:id` or `:name` would redact every parameter in the app that happens to contain it — occasionally a worse outcome than the leak it prevents. An explicit `sensitive: false` on a field (or on a container, for its whole subtree) keeps it readable. Only `false` opts out — `sensitive: nil` reads as "not stated" and still inherits.
31
+
32
+ ### Changed
33
+ - **A `Time` passed to a `:datetime` field is no longer converted in place.** Normalising to UTC went through `value.to_time.utc`; `Time#to_time` returns `self` and `Time#utc` converts its **receiver**, so validating a request quietly rewrote the caller's own object — after `call!(at: t)`, `t` had become UTC. The cast now returns a new instance and leaves the argument alone. An `ActiveSupport::TimeWithZone` is likewise no longer handed back by way of the UTC instance it caches internally.
34
+ - **`length:` is now checked before `in:` and `format:`, so a value the bound already excludes never pays for the expensive checks.** `length:` is an O(1) read of a String's size; `format:` runs a regexp over the whole value and `validate:` runs arbitrary app code. Checking the cheap bound *last* meant a 5 MB string against `length: 1..80` was scanned in full by the field's regexp before being rejected on its length — 121 ms where 38 ms would do, and a lever rather than mere waste when the app's regexp has poor worst-case behaviour. The documented order is now `normalize: → cast → length: → in: → format: → validate:`, with the first failure reported. The only observable change is which code a value violating **both** reports — `length` now, rather than `inclusion`/`format` — and that is the more useful answer anyway, since a client cannot act on "wrong format" for a value that is also far too long. A spec pins that the field's regexp is not consulted at all for an over-long value.
35
+
36
+ ### Upgrading
37
+ - **A contract that already declares `sensitive: true` on a nested block or array will redact more than it did before.** That is the point of the fix, but the widening is app-wide and worth a look before deploying: every cascaded child's name is registered as a case-insensitive **substring** filter, so a child called `id`, `name`, `type`, `status` or `zip` starts redacting `user_id`, `company_name`, `content_type` and `gzip` in **every** controller's logs, not only in the contract that declared it. Run `grep -n "sensitive: true" app/controllers` and add `sensitive: false` to any child whose name is too generic to filter globally.
38
+
39
+ ## 0.7.0 (2026-09-16)
40
+ <!-- title: sensitive: redaction, uncorruptible defaults, and stricter class load -->
41
+
42
+ Two silent disclosures and three silent corruptions, all in code that was doing its job wrongly rather than not at all: `sensitive:` redacted nothing unless the value was a String, a swapped filter registry stopped being consulted, a mutable `default:` was shared by every request in the process, `normalize:` could manufacture an empty value that walked past `required`, and `unknown: :error` rejected the keys Rails itself adds to a form POST. Contract mistakes that could only ever fail at request time now fail at class load instead.
43
+
44
+ ### Fixed
45
+ - **Swapping `Permittable.filter_parameter_registry` silently stopped `sensitive:` redaction.** `Permittable::Railtie` appended `filter_parameter_registry.to_proc` — a proc bound to whichever registry instance existed **at boot**. Rails runs railtie initializers *before* `config/initializers`, so a host gem or app that swaps the registry necessarily does so afterwards, leaving Rails filtering through the old instance: `sensitive:` fields registered themselves in the new registry, and the appended proc went on consulting an empty one. The parameter was logged in the clear, with nothing to indicate it. That swap is the reason the writer exists — the gem's own comment names `concerns_on_rails` as doing exactly this — so the broken ordering was the normal case rather than an exotic one. The Railtie now appends `Permittable.filter_parameter_proc`, which resolves the registry at **filter time**; it is one frozen object for the life of the process, so the Railtie's idempotence check still holds across repeated initializer runs.
46
+ - **`sensitive:` redacted nothing unless the value was a String.** The mechanism was a proc filter, and ActiveSupport's `ParameterFilter` dups the value before invoking one and expects in-place mutation — so `optional :pin, :integer, sensitive: true` logged `1234` in the clear, and `sensitive: true` on a nested block logged the whole hash, since `ParameterFilter` checks `value.is_a?(Hash)` *before* the proc filters and recurses into it instead of ever calling them. Both are silent disclosures of exactly the fields a contract marked as the ones not to print. Each registered name is now **also added to `config.filter_parameters` by name**, which redacts a value of any type, while the proc stays for what only it can reach (consumers that snapshot the array at boot). A real Rails boot covers an `:integer` field, a sensitive nested block, and `#inspect`, with `precompile_filter_parameters` on — the modern default, and the reason the mechanism has to be a name rather than a live matcher object: precompilation joins filters by pattern source and discards anything whose matching is decided at filter time. `Permittable.on_sensitive_parameter` is the seam the Railtie installs, so the registry itself stays free of Rails.
47
+ - **A mutable `default:` was shared by every request.** Field declarations are frozen data, but the *value* an author wrote for `default:` was not, and `HashWithIndifferentAccess` hands a non-frozen Array — and any String — to the result **by reference**. So `array :tags, of: :string, default: []` gave every request the same Array: one request appending to `permitted_params[:tags]` corrupted the default for every later request in the process, and `Model.new(tags: …)` assigns that same object, so `record.tags << x` was enough to trigger it. The corruption outlived the request and lasted for the life of the process. A `default:` (and a documentation `example:`) is now **deep-copied and frozen at class load**, so the contract cannot be corrupted, and each request is handed its own copy. The copy is the point: the object the host app passed in is never frozen, in case it is still using it.
48
+ - **`normalize:` could manufacture an empty value that walked past `required`.** `""` is documented as absent, so a required field must violate — but normalization ran *after* absence had already been decided. `required :name, :string, normalize: :squish` therefore rejected `""` as `missing` and **accepted `" "` as `""`**, writing an empty string into the column: exactly the silent corruption strict coercion exists to refuse, delivered by the gem's own preset. `normalize:` now runs **first**, before the absence rule, so a value that normalizes to empty is absent like any other empty value — it takes the `nullable:` / `default:` / `missing` branch. There is still exactly one reading of absence, and a `normalize:` Proc is still called exactly once per value. Relatedly, a `default:` is now **stored** in the form it was validated in: `default: " free "` with `normalize: :squish` was checked as `"free"` and used to be handed to requests as `" free "`.
49
+ - **`unknown: :error` rejected ordinary form submissions.** Only the router's `controller`/`action`/`format` were exempt from the top-level unknown-key check, but Rails also merges `authenticity_token`, `_method`, `utf8` and `commit` into a form POST — so the strictest setting was unusable outside a JSON API, and every browser form failed on four of the framework's own keys rather than on anything the client got wrong. Those four are now exempt at the top level too. The exemption covers the *check* only, and only at the top level: a form key smuggled inside a `root:` or a nested hash is still `unknown`, a standalone `Permittable::Contract` still exempts nothing (it has neither a router nor a form), and monitor mode still passes the form keys through in its raw hash, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `_method` itself.
50
+ - **An exported `pattern` could be stricter than the rule the server enforces.** Ruby's `^` and `$` anchor a **line**; ECMA-262's, without the `m` flag, anchor the whole string. So `format: /^\d{5}$/` accepts `"evil\n12345"` at runtime while the exported `"pattern": "^\\d{5}$"` rejects it — the documentation and the enforcement disagreeing, which is the one thing an export from contract data is meant to make impossible. Such a regexp now joins the constructs that stay visible as `x-permittable-pattern` instead of being mistranslated, alongside `\Z`, `\h` and the POSIX classes. Straight after `[` neither one is an anchor — `^` is class negation and `$` a literal — so `[^a]` and `[$]` still translate, as does an escaped `\$` or `\^`. `\A`/`\z` translate exactly and remain the anchors to reach for.
51
+
52
+ - **A block array's `default:` was checked against a different reading of absence than the request it stands in for.** `""` is absent, so a client sending `items: [{ "sku" => "" }]` is refused with `items[0].sku missing` — but the class-load check for a block array's `default:` treated only `nil` as absent, so `default: [{ "sku" => "" }]` was accepted, and a `default:` is applied **without revalidation**: every request that omitted the key was handed the exact value the contract refuses from a client. The same held for a value that normalizes to empty, because the check normalized *after* deciding absence rather than before. That check now reads absence the way the walker does — normalize first, then `nil`/`""`, with `nullable:` splitting the rule for an explicitly-empty value exactly as it does at request time — and the value half of that rule is now one shared predicate rather than two spellings of it.
53
+
54
+ Contracts that declare no `default:`, no `normalize:`, and no `unknown: :error` are unaffected.
55
+
56
+ ### Added
57
+ - **Swapping the registry now carries the entries it already holds into the new one.** Late-binding the proc fixes redaction for contracts that load *after* a swap, but on its own it breaks the other half: nothing consults the outgoing registry again, so a `sensitive:` field registered by a contract that loaded *before* the swap would have stopped being redacted — the exact mirror image of the bug above, and the case a host gem pooling registrations is most likely to hit, since eager loading in production loads plenty of controllers before `config/initializers` runs. `Permittable.filter_parameter_registry=` now re-adds each name from the outgoing registry (read through a new duck-typed `#names`) to the incoming one, and a real Rails boot covers both halves.
58
+ - **`Permittable.filter_parameter_registry=` validates what it is given.** A registry with no `#to_proc` used to be accepted silently and simply never consulted; with the proc late-bound it would instead have raised `NoMethodError` inside `process_action` on every request. It now raises `ArgumentError` at the point of the swap, naming the class. The registry's callable may take Rails' two-argument (`key, value`) or three-argument (`key, value, original_params`) proc-filter shape; both are dispatched by arity.
59
+
60
+ ### Changed
61
+ - **A bound that no value could satisfy now fails at class load.** A reversed or empty `Range` excludes every value there is, so the field it bounds could never validate — and that surfaced as every request to the action failing on that field: a contract mistake reported to clients as their error, once per request, forever. `in: 65..18`, `length: 5..2`, `length: 3...3`, an empty `in: []`, a negative `length:`, and a `length:` of 0 on a `required` field (where `""` is absent and already violates as `missing`, so nothing is left to accept) are now `ArgumentError` at class load, naming the bound. Endless and beginless Ranges are legitimate and unaffected, as are endpoints that cannot be compared. **Breaking** for a contract that ships such a field, but only for one that was already failing 100% of the requests that reached it.
62
+ - **An array declared with a block now checks its `default:` too.** `validate_array_authored_value!` only checked elements against `of:`, which is nil for a block array — so `array :items, default: [{ "nonsense" => true }] do required :sku, :string end` was accepted at class load and handed to every request that omitted the key, bypassing the contract the block declares. Elements are now checked against the block's own fields (required sub-fields present, scalar ones satisfying their own contract), the same shallow check `of:` gets. **Breaking** for a contract whose block-array default was already wrong, which previously returned that value rather than rejecting it.
63
+
64
+
3
65
  ## 0.6.0 (2026-09-08)
4
66
  <!-- title: nullable fields, :json, and strict dates -->
5
67
 
data/README.md CHANGED
@@ -210,7 +210,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
210
210
  request params
211
211
  │
212
212
  ├─ 1 unwrap root: params[:user] missing or not a hash → 400
213
- ├─ 2 each field normalize → cast → validate → transform
213
+ ├─ 2 each field normalize → absent? → cast → validate → transform
214
214
  ├─ 3 unknown-key check at every nesting level (unknown: :ignore | :log | :error)
215
215
  ├─ 4 finalize only when nothing violated
216
216
  │
@@ -270,7 +270,7 @@ end
270
270
 
271
271
  # Arrays — of: for scalars, a block for hashes. Element failures carry their index: items[1].sku
272
272
  array :tag_names, of: :string, length: 0..10
273
- array :line_items, required: true do
273
+ array :line_items, required: true, length: 1..50 do
274
274
  required :sku, :string
275
275
  required :quantity, :integer, in: 1..99
276
276
  end
@@ -281,6 +281,8 @@ optional :metadata, :json, max_depth: 3, length: 0..32
281
281
 
282
282
  Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
283
283
 
284
+ `length:` is a **bound, not a report**: an array outside it is rejected without its elements being examined at all. A 200,000-element payload against `length: 0..10` is refused by its first check, so it costs one violation and a 40-byte body instead of 200,001 violations and several megabytes — milliseconds of contract work instead of seconds. There is **no default cap**: an array with no `length:` is unbounded, and every element of it is cast and checked however many arrive. Declare `length:` on every array you accept.
285
+
284
286
  ### Field options
285
287
 
286
288
  Which options are legal depends on the field kind — anything else raises at class load.
@@ -289,9 +291,9 @@ Which options are legal depends on the field kind — anything else raises at cl
289
291
  |---|:---:|:---:|:---:|---|
290
292
  | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
291
293
  | `format:` | ✅¹ | — | — | Regexp the value must match |
292
- | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays |
293
- | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **before** the cast |
294
- | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load |
294
+ | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
295
+ | `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, then stored normalized and frozen (each request gets its own copy) |
295
297
  | `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
296
298
  | `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
297
299
  | `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
@@ -306,6 +308,16 @@ Which options are legal depends on the field kind — anything else raises at cl
306
308
 
307
309
  ¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
308
310
 
311
+ **Checks run in a fixed order**, and the first failure is the one reported:
312
+
313
+ ```
314
+ normalize: → cast → length: → in: → format: → validate:
315
+ ```
316
+
317
+ `length:` comes before `in:` and `format:` on purpose. It is an O(1) read of a string's size, while `format:` runs a regexp over the whole value and `validate:` runs your own code — so a value the length bound already excludes never pays for the expensive checks. A 5 MB string against `length: 1..80` is rejected on its length without the regexp ever seeing it, which matters most when the regexp is one with poor worst-case behaviour.
318
+
319
+ The visible consequence: a value that violates *both* its length and its format reports `length`. That is the more useful answer anyway — a client can't act on "wrong format" for a value that is also far too long.
320
+
309
321
  `validate:` is the escape hatch for anything the built-ins don't cover:
310
322
 
311
323
  ```ruby
@@ -329,6 +341,8 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
329
341
 
330
342
  **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.
331
343
 
344
+ **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
+
332
346
  Two more behaviours worth committing to memory:
333
347
 
334
348
  - **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.
@@ -365,7 +379,7 @@ In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{
365
379
 
366
380
  ### Absence, defaults, and partial updates
367
381
 
368
- `nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present.
382
+ `nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present. `normalize:` runs *before* this rule, so a field declared `normalize: :squish` treats `" "` as absent too: whitespace cannot satisfy a `required` field by becoming `""`.
369
383
 
370
384
  That single rule produces the behaviour you want from a `PATCH`:
371
385
 
@@ -414,8 +428,8 @@ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an ar
414
428
 
415
429
  | Code | Raised when |
416
430
  |---|---|
417
- | `missing` | A required field is absent, or the `root:` key is missing (this one is a **400**) |
418
- | `invalid_type` | The value cannot be faithfully cast to the declared type |
431
+ | `missing` | A required field is absent, or the `root:` key is absent (that one is a **400**) |
432
+ | `invalid_type` | The value cannot be faithfully cast to the declared type — including a `root:` key the client *did* send with the wrong shape (`{"user": "bob"}`), which is also a **400** |
419
433
  | `inclusion` | The value is outside `in:` |
420
434
  | `format` | The value doesn't match `format:` |
421
435
  | `length` | A string's length, or an array's element count, is outside `length:` |
@@ -425,7 +439,9 @@ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an ar
425
439
 
426
440
  Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
427
441
 
428
- **Status codes.** A missing root key renders **400** — the request is malformed; the envelope you asked for isn't there. Field-level violations render **422** — well-formed, semantically wrong.
442
+ **`details` is complete; `message` is prose.** The `details` array names **every** offender, however many there are — it is the machine-readable channel and nothing is dropped from it. The `message` string is a sentence for a person, and it also lands in your logs and in every exception tracker, so it is bounded: at most ten offenders, each truncated past 120 characters, then a count of the rest (`…, and 49990 more`). Before that bound, a request carrying 50,000 undeclared keys against `unknown: :error` produced a **1 MB** exception message and a 1 MB log line. The 422 body still carries the complete `details`, so it stays proportional to the number of violations; the field bounds are what keep that number down.
443
+
444
+ **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.
429
445
 
430
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. For full control over the body (RFC 9457, a different envelope), `error.details` gives you the structured violations to build from.
431
447
 
@@ -484,10 +500,14 @@ Resolution order per violation: the field's own `message:` (String, or the Hash
484
500
  | Mode | Behaviour |
485
501
  |---|---|
486
502
  | `:ignore` (default) | Silently dropped, exactly like strong parameters |
487
- | `:log` | Dropped, with a `logger.warn` naming the full paths |
503
+ | `:log` | Dropped, with a `logger.warn` naming the full paths — at most ten of them, then a count, so one request cannot write a megabyte of log |
488
504
  | `:error` | Each undeclared key becomes an `unknown` violation |
489
505
 
490
- Rails merges `controller`, `action`, and `format` into `params`; these are exempt at the top level so `unknown: :error` doesn't flag the router's own bookkeeping. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there.
506
+ 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
+
508
+ Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token`, `_method`, `utf8`, and `commit` from an ordinary form POST. All seven are exempt at the top level, so `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 nor a form.
509
+
510
+ The exemption covers the *check* only. Monitor mode still hands back the form keys in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `_method` itself; only the router's three are dropped there.
491
511
 
492
512
  ### Output reshaping (`transform:` and `finalize`)
493
513
 
@@ -553,13 +573,35 @@ Mark a field `sensitive: true` and its name is registered with `Permittable.filt
553
573
  optional :ssn, :string, sensitive: true
554
574
  ```
555
575
 
556
- The indirection is deliberate. Appending plain symbols to `config.filter_parameters` at class-load time misses every consumer that snapshots the list at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers, precompiled filters. A **single proc appended once at boot, consulting a live registry at filter time**, means fields registered when a controller loads later (lazy loading in development) are still redacted. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
576
+ **On a nested block or an array, `sensitive:` cascades to everything inside it:**
577
+
578
+ ```ruby
579
+ optional :payment, sensitive: true do
580
+ required :card_number, :string # redacted
581
+ optional :cvv, :string # redacted
582
+ optional :id, :string, sensitive: false # NOT redacted — see below
583
+ end
584
+ ```
585
+
586
+ It has to. Rails' parameter filtering walks into hashes and arrays itself and asks a proc filter about the **leaf values only**, handing it the leaf's own key and never the path that led there. So registering `payment` alone redacts nothing inside it: the filter descends and asks about `card_number`, which the container's name does not match.
587
+
588
+ A sub-field opts out with an explicit `sensitive: false`. That exists because matching is a case-insensitive **substring** match, so cascading a generic name like `:id` or `:name` would redact every parameter in the app that happens to contain it — occasionally a worse outcome than the leak it prevents. Only `false` opts out; `sensitive: nil` reads as "not stated" and still inherits.
589
+
590
+ The cascade is resolved onto the field when the contract loads, so everything that reads a contract agrees: the value is redacted from logs, the exported schema marks the child `writeOnly`, and `permit_param("payment.card_number").sensitive` passes.
591
+
592
+ Two mechanisms, because neither covers the ground alone.
593
+
594
+ A **single proc appended once at boot, consulting a live registry at filter time**, is what reaches consumers that snapshot `config.filter_parameters` at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers — so a field registered when a controller loads later (lazy loading in development) is still redacted there. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
595
+
596
+ But a proc filter can only redact **String** values: ActiveSupport dups the value and expects in-place mutation, and for a `Hash` value it never calls the proc at all, recursing into it instead. So each name is **also registered as a name** in `config.filter_parameters`, which redacts a value of any type — an `:integer` field, or a whole sensitive nested block. Appending later still works: Rails' `precompile_filter_parameters` replaces that array *in place*, and `ActionDispatch` reads the same object on every request, so a name registered at class-load time is seen by the next request. (This is also why the mechanism is a name and not a live matcher object: precompilation joins patterns by source, which discards anything whose matching is decided at filter time.)
597
+
598
+ Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#names`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own. `#to_proc` must return a callable of arity 2 (`key, value`) or 3 (`key, value, original_params`), matching what Rails' own parameter filtering accepts; anything that does not respond to `#to_proc` is refused at the point of the swap rather than on the next request.
557
599
 
558
- Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own.
600
+ **The swap works at any point**, including from `config/initializers` — which matters, because Rails runs railtie initializers *before* those, so a swap always happens after `Permittable::Railtie` has appended its filter. Two things make that safe. The appended proc (`Permittable.filter_parameter_proc`) resolves the registry at **filter time** rather than closing over whichever instance existed at boot, so whichever registry is current does the redacting. And the swap **carries the previous registry's names into the new one**, so a `sensitive:` field registered by a contract that loaded before the swap keeps being redacted afterwards. Without that, the two halves of an app would each redact only what the other did not.
559
601
 
560
602
  ### Instrumentation
561
603
 
562
- Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
604
+ Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on — **exactly once per action per request**, however many times the action reads the params (`permitted_params` memoizes the outcome, rejections included):
563
605
 
564
606
  ```ruby
565
607
  ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
@@ -638,7 +680,7 @@ permit_params :create, :update, root: :user, model: User, mode: :monitor do
638
680
  optional :age, :integer
639
681
  optional :status, :string # database default: "active"
640
682
  optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
641
- array :tag_names, of: :string # TODO: confirm the element type
683
+ array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
642
684
  end
643
685
  ```
644
686
 
@@ -646,6 +688,7 @@ The generator's one rule is **draft, don't guess** — everything it cannot know
646
688
 
647
689
  - Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
648
690
  - A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no scalar equivalent (`json`, `binary`) 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
+ - **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.
649
692
  - 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.
650
693
  - `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes.
651
694
 
@@ -728,7 +771,7 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
728
771
 
729
772
  Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
730
773
 
731
- **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
774
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern` — including one 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.
732
775
 
733
776
  Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
734
777
 
@@ -764,7 +807,7 @@ Output is deterministic (fixed key order, declaration-order properties), so the
764
807
 
765
808
  | Method | Purpose |
766
809
  |---|---|
767
- | `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation (in [monitor mode](#monitor-mode-roll-out-without-rejecting), returns the raw pass-through instead), or `ArgumentError` when no contract covers the action |
810
+ | `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Raises `InvalidParameters` on violation (in [monitor mode](#monitor-mode-roll-out-without-rejecting), returns the raw pass-through instead), or `ArgumentError` when no contract covers the action. **Memoized per action, outcome included** — a rejection is re-raised rather than revalidated, so a contract runs (and instruments) exactly once per action per request |
768
811
  | `permittable_violations(action = action_name)` | The violation details recorded by validating `action` — `[]` when clean. Triggers the same memoized validation; under enforce it swallows the raise, making "would this request fail?" a one-liner |
769
812
  | `enforce_params_contract` | The `before_action` entry point. Validates rules declared `enforce: true` and all [monitor-mode](#monitor-mode-roll-out-without-rejecting) rules. Public, so hosts can `skip_before_action` it |
770
813
  | `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
@@ -782,7 +825,8 @@ Output is deterministic (fixed key order, declaration-order properties), so the
782
825
  | Constant | Purpose |
783
826
  |---|---|
784
827
  | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
785
- | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
828
+ | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry; entries already registered are carried across |
829
+ | `Permittable.filter_parameter_proc` | The single proc `Permittable::Railtie` appends to `config.filter_parameters`; consults the current registry at filter time |
786
830
  | `Permittable.mode` / `Permittable.mode=` | App-wide default (`:enforce`) for rules that don't declare their own `mode:` |
787
831
  | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
788
832
  | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
@@ -805,9 +849,10 @@ A bad contract is a programmer error, so it fails when the class loads — never
805
849
  - An unknown type, listing the supported ones
806
850
  - An unknown `normalize:` preset, listing the presets
807
851
  - `format:`, `length:`, or `normalize:` on a non-`:string` field
808
- - `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
852
+ - `length:` that isn't a non-negative `Integer` or a `Range`; `in:` that doesn't respond to `include?`
853
+ - 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`)
809
854
  - `validate:` or `transform:` that isn't callable
810
- - A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
855
+ - 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
811
856
  - A `default: nil` or `example: nil` on a field that isn't `nullable:`
812
857
  - A `:json` field's `default:`/`example:` that isn't a Hash, or that its own `length:`/`max_depth:` would reject
813
858
  - A `max_depth:` that isn't a positive Integer
@@ -10,8 +10,9 @@ module Permittable
10
10
  #
11
11
  # Matching mirrors Rails symbol-filter semantics: case-insensitive substring
12
12
  # match on the parameter key. The whole object is duck-typed (#add,
13
- # #include?, #to_proc, #reset!) so a host can swap in its own registry via
14
- # `Permittable.filter_parameter_registry=` and pool registrations.
13
+ # #include?, #to_proc, #names, #reset!) so a host can swap in its own
14
+ # registry via `Permittable.filter_parameter_registry=` and pool
15
+ # registrations.
15
16
  class FilterParameterRegistry
16
17
  FILTERED = "[FILTERED]".freeze
17
18
 
@@ -55,6 +56,14 @@ module Permittable
55
56
  @proc
56
57
  end
57
58
 
59
+ # The names registered so far. Permittable.filter_parameter_registry=
60
+ # reads this off the outgoing registry and re-adds each name to the
61
+ # incoming one, so a swap never un-redacts a field that a contract
62
+ # loaded before it had already registered.
63
+ def names
64
+ @mutex.synchronize { @fields.to_a }
65
+ end
66
+
58
67
  # Spec hygiene — the registry is process-global.
59
68
  def reset!
60
69
  @mutex.synchronize do
@@ -1,3 +1,5 @@
1
+ require "ripper"
2
+
1
3
  module Permittable
2
4
  # Drafts a permit_params contract from what the app already knows: the
3
5
  # model's columns (types, NOT NULL, database defaults) and, when the
@@ -54,14 +56,38 @@ module Permittable
54
56
  ARRAY_ARG = /\A(\w+):\s*\[\s*\]\z/m
55
57
  NESTED_ARG = /\A(\w+):\s*\[([^\[\]]*)\]\z/m
56
58
 
59
+ # Comment tokens. Ripper (stdlib) is used rather than a regexp because `#`
60
+ # is only a comment sometimes — it also appears inside string literals and
61
+ # `#{}` interpolation, and a permit call inside interpolation IS live code.
62
+ # String CONTENT is deliberately kept: `permit("name")` is a supported
63
+ # spelling, and its keys live in string tokens.
64
+ COMMENT_TOKENS = %i[on_comment on_embdoc on_embdoc_beg on_embdoc_end].freeze
65
+
57
66
  module_function
58
67
 
68
+ # `source` with its comments removed. A controller keeping a commented-out
69
+ # `params.require(:admin).permit(:superuser)` for reference had :admin
70
+ # drafted as its root and :superuser as a permitted field — a wrong
71
+ # suggestion, and a security-flavoured one, from a line that does not run.
72
+ #
73
+ # Anything Ripper cannot lex falls back to the source unchanged, so a
74
+ # syntactically odd file scans exactly as it did before rather than not at
75
+ # all.
76
+ def executable_source(source)
77
+ tokens = Ripper.lex(source)
78
+ return source if tokens.nil? || tokens.empty?
79
+
80
+ tokens.reject { |token| COMMENT_TOKENS.include?(token[1]) }.map { |token| token[2] }.join
81
+ rescue StandardError
82
+ source
83
+ end
84
+
59
85
  # Merge every permit call found in `source` into one Scan. The first
60
86
  # `.require(:root)` seen wins, matching how a controller normally sticks
61
87
  # to one envelope across actions.
62
88
  def scan(source)
63
89
  result = Scan.new(root: nil, scalars: [], arrays: [], nested: {}, unparsed: [], calls: 0)
64
- (source || "").scan(PERMIT_CALL) do |root, args|
90
+ executable_source(source.to_s).scan(PERMIT_CALL) do |root, args|
65
91
  result.calls += 1
66
92
  result.root ||= root&.to_sym
67
93
  split_args(args).each { |arg| classify_arg(result, arg) }
@@ -192,7 +218,9 @@ module Permittable
192
218
 
193
219
  def scanned_lines(scan, columns)
194
220
  lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
195
- lines += scan.arrays.map { |name| "array :#{name}, of: :string # TODO: confirm the element type" }
221
+ lines += scan.arrays.map do |name|
222
+ "array :#{name}, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded"
223
+ end
196
224
  scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
197
225
  lines + scan.unparsed.map { |arg| "# TODO: could not parse from the permit call: #{arg}" }
198
226
  end
@@ -33,16 +33,23 @@ module Permittable
33
33
 
34
34
  # Ruby regexp constructs with no ECMA-262 equivalent (\Z, \h, \K, \R, \G,
35
35
  # inline flag groups, absence operator, conditionals, POSIX classes,
36
- # possessive quantifiers). A source matching this is left untranslated —
37
- # the scan is deliberately over-eager on escaped lookalikes because a
38
- # wrong pattern in published docs is worse than a missing one.
36
+ # possessive quantifiers) — and Ruby's ^ and $, which anchor a LINE where
37
+ # ECMA-262 without the m flag anchors the whole string. /^\d{5}$/ accepts
38
+ # "evil\n12345" at runtime, so emitting its source as `pattern` would
39
+ # publish a rule stricter than the server enforces, and an export from
40
+ # contract data is supposed to make that impossible. Straight after a [
41
+ # neither is an anchor — ^ is class negation and $ is a literal — so both
42
+ # stay translatable there. Otherwise the scan is deliberately over-eager
43
+ # on escaped lookalikes, because a wrong pattern in published docs is
44
+ # worse than a missing one.
39
45
  UNTRANSLATABLE = /
40
46
  \\[ZhHKRG] |
41
47
  \(\?[a-z-]+[:)] |
42
48
  \(\?~ |
43
49
  \(\?\( |
44
50
  \[\[: |
45
- [*+?]\+
51
+ [*+?]\+ |
52
+ (?<![\\\[])[\^$]
46
53
  /x
47
54
 
48
55
  # Request-body schema for one rule from `permittable_contracts` /
@@ -11,8 +11,9 @@ module Permittable
11
11
  # Everything the exporter cannot know is left visible rather than guessed:
12
12
  # actions covered only by a catch-all rule on a host without
13
13
  # `action_methods` appear under the "*" key with `x-permittable-catch-all`,
14
- # and operations with no matching route land in `x-permittable-controllers`
15
- # instead of being dropped silently.
14
+ # and operations with no matching route — or whose path+verb slot another
15
+ # controller already claimed, which a document cannot represent twice —
16
+ # land in `x-permittable-controllers` instead of being dropped silently.
16
17
  module OpenAPI
17
18
  module_function
18
19
 
@@ -39,8 +40,15 @@ module Permittable
39
40
  },
40
41
  "code" => {
41
42
  "type" => "string",
42
- "description" => "missing / invalid_type / inclusion / format / length / unknown / invalid, " \
43
- "or a contract-specific symbol"
43
+ "description" => "missing / invalid_type / inclusion / format / length / depth / unknown / " \
44
+ "invalid, or a contract-specific symbol"
45
+ },
46
+ # Present only when the field declares `message:` or the app
47
+ # has I18n copy for the code; a violation without one keeps
48
+ # the bare { param:, code: } shape, so this is not required.
49
+ "message" => {
50
+ "type" => "string",
51
+ "description" => "Human-readable copy for this violation, when the contract or I18n supplies it"
44
52
  }
45
53
  },
46
54
  "required" => %w[param code]
@@ -176,15 +184,43 @@ module Permittable
176
184
  def place_operations(controller, operations, routes, paths, unrouted)
177
185
  key = controller_key(controller) || controller.inspect
178
186
  operations.each do |action, operation|
179
- matched = routes_for(routes, key, action)
180
- if matched.empty?
187
+ # A path+verb pair carries exactly one operation, so a slot another
188
+ # controller already claimed is not written over: the loser stays
189
+ # visible under x-permittable-controllers, where an operation with no
190
+ # route at all lands, rather than disappearing from the document.
191
+ free = routes_for(routes, key, action).reject { |route| paths.dig(route[:path], verb_of(route)) }
192
+ if free.empty?
181
193
  (unrouted[key] ||= {})[action] = operation
182
194
  else
183
- matched.each { |route| (paths[route[:path]] ||= {})[route[:verb].to_s.downcase] = operation }
195
+ free.each { |route| (paths[route[:path]] ||= {})[verb_of(route)] = with_path_parameters(operation, route[:path]) }
184
196
  end
185
197
  end
186
198
  end
187
199
 
200
+ def verb_of(route)
201
+ route[:verb].to_s.downcase
202
+ end
203
+
204
+ # OpenAPI 3.1 requires every variable in a path template to be declared as
205
+ # a path parameter — a document templating {id} without declaring it is
206
+ # invalid, which every member route produced. The route set does not say
207
+ # what an :id is and the exporter does not guess: a path segment arrives as
208
+ # a string, so that is what it is documented as.
209
+ def with_path_parameters(operation, path)
210
+ variables = path.scan(/\{(\w+)\}/).flatten
211
+ return operation if variables.empty?
212
+
213
+ parameters = variables.map do |name|
214
+ { "name" => name, "in" => "path", "required" => true, "schema" => { "type" => "string" } }
215
+ end
216
+ # Inserted ahead of requestBody, where a reader of the document expects
217
+ # it; emission stays deterministic either way.
218
+ operation.each_with_object({}) do |(key, value), out|
219
+ out["parameters"] = parameters if key == "requestBody"
220
+ out[key] = value
221
+ end
222
+ end
223
+
188
224
  def routes_for(routes, controller_key, action)
189
225
  return [] if routes.nil? || action == "*"
190
226
 
@@ -194,16 +230,23 @@ module Permittable
194
230
  # { controller:, action:, verb:, path: } descriptors from a Rails
195
231
  # application's route set. Duck-typed against Journey routes (each one
196
232
  # responds to requirements / verb / path.spec) so it stays unit-testable
197
- # without Rails; Rails path params (:id) become OpenAPI templates ({id}).
233
+ # without Rails; Rails path params become OpenAPI templates — both the
234
+ # `:id` form and the `*rest` wildcard, which is a real route shape
235
+ # (`get "files/*path"`) and is not a valid OpenAPI template left as-is.
198
236
  def rails_routes(app)
199
- app.routes.routes.filter_map do |route|
237
+ app.routes.routes.flat_map do |route|
200
238
  requirements = route.requirements
201
239
  verb = route.verb.to_s
202
- next if requirements[:controller].nil? || requirements[:action].nil? || verb.empty?
240
+ next [] if requirements[:controller].nil? || requirements[:action].nil? || verb.empty?
203
241
 
204
- path = route.path.spec.to_s.sub("(.:format)", "").gsub(/:(\w+)/) { "{#{Regexp.last_match(1)}}" }
205
- { controller: requirements[:controller], action: requirements[:action],
206
- verb: verb.split("|").first.downcase, path: path }
242
+ path = route.path.spec.to_s.sub("(.:format)", "").gsub(/[:*](\w+)/) { "{#{Regexp.last_match(1)}}" }
243
+ # One route can answer several verbs (`match via: [:patch, :put]`, and
244
+ # the PATCH|PUT pair resources generates); documenting only the first
245
+ # dropped the others from the export entirely.
246
+ verb.split("|").map do |single|
247
+ { controller: requirements[:controller], action: requirements[:action],
248
+ verb: single.downcase, path: path }
249
+ end
207
250
  end
208
251
  end
209
252
 
@@ -9,8 +9,24 @@ module Permittable
9
9
  class Railtie < Rails::Railtie
10
10
  initializer "permittable.filter_parameters",
11
11
  before: "active_record.set_filter_attributes" do |app|
12
- filter = ::Permittable.filter_parameter_registry.to_proc
12
+ # Late-bound on purpose — see Permittable.filter_parameter_proc. This
13
+ # initializer runs before config/initializers, so a registry swapped
14
+ # there must still be the one consulted at filter time.
15
+ filter = ::Permittable.filter_parameter_proc
13
16
  app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter)
17
+
18
+ # The proc above redacts Strings, live, and survives precompilation.
19
+ # What it cannot reach is a value that is not a String — ParameterFilter
20
+ # mutates values in place for proc filters, and skips them entirely for
21
+ # a Hash — so each registered name is ALSO added by name, which redacts
22
+ # any value type. Appending later still works: precompilation `replace`s
23
+ # this array in place and ActionDispatch reads the same object per
24
+ # request, so a name registered when a controller loads is seen by the
25
+ # next request. It is the array, not a snapshot, that has to be fed.
26
+ ::Permittable.on_sensitive_parameter do |name|
27
+ filters = app.config.filter_parameters
28
+ filters << name unless filters.include?(name)
29
+ end
14
30
  end
15
31
 
16
32
  rake_tasks do
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.6.0".freeze
2
+ VERSION = "0.8.0".freeze
3
3
  end