permittable 0.13.1 → 0.14.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: bbb9461a44e4918ca7a982fc42ad55e7c2c6a36b231ea9560eb298c85fea259c
4
- data.tar.gz: 324137849cfc36dde9a3ff2764aeef2ba02beec9e6487bd70b4990b0ee3b95e9
3
+ metadata.gz: a884eb52cc555157fddf46346de7995e12c1da2920e8488cfae7d7f383123824
4
+ data.tar.gz: dae0d105fb44f1a0dac9cc07b33fd583e3e5fc5952f5511bb46126a56ae229ad
5
5
  SHA512:
6
- metadata.gz: f612dc7e5b04cf543c34b4b9ff4810e0620f80a8dfcc6cbed92d2969eab40a4396b3472d9ac2f8b4a37bc1b1a8d92ddc166312edba7de78508de2c4435dcba31
7
- data.tar.gz: 7dd8145f4968a76cd10332a17f74a30531a4c4d549becd967b80c5f2a7f4ad41b54751209abf94b9386aa6a0aaa939d3be8f1eb1a3dd1b309f05bd39c5d212d6
6
+ metadata.gz: 63a2d785b2067d7d581d13775da06c180db170597acaad22a9c3de0622a7592f79a7ccf8febcb6199a53954ac03bfa606e781110927e02834145c313dcb6b366
7
+ data.tar.gz: baca9f1599f2637e68ab407bb81861d342fc79b43c6a836aa6e165e47acf539c6f3625fcdc07cfe4c5dd9c9e1b1e69de75df481dffc7d95126e0ae3f52ac383d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.14.0 (2026-11-09)
4
+ <!-- title: a :file field type with a multipart export, date strings read as the instant they name, Metal controllers that enforce whatever the include order, and matchers that see what a request sees -->
5
+
6
+ Two additions and six changes. A new `:file` field type, and `of: :file`, accept an uploaded file, and the OpenAPI export describes an operation with a file field as `multipart/form-data` (#165). A `:date` or `:datetime` string that carries a time or an offset is read as the instant it names, or refused, where it was read as another day or another hour (#153, #166). A bare `ActionController::Metal` controller enforces its rules whichever order its modules are included, and raises at class load where the gem cannot register a hook (#155). The matchers run a controller payload through Rails' deep munge (#154). The OpenAPI export drops a name the route fills from `defaults:` from the request body (#156), and a blank default on a nullable field reads as `nil` (#160).
7
+
8
+ Minor rather than patch: each entry can make a request, a boot or a spec that passes today fail, or change what an action or an export receives. Before upgrading, read the entries that match what you use. A request can now be refused: a `:date` string with a time of day other than midnight UTC, or with an offset that moves it off its day (#153), a `:date` or `:datetime` string such as `"2026-09-05 -0500"`, `"Sat, 05 Sep 2026 -0500"`, `"2026/09/05 -05:00"` or `"2026-09-05T+05:00"` that Ruby read as a time or dropped (#166), and a `:date` `"1500-02-29"` once it carries a time. A boot can now fail: a bare `ActionController::Metal` controller with an `enforce: true` or `mode: :monitor` rule and no `AbstractController::Callbacks`, or an `enforce: true` rule and no `ActionController::Rescue`, or with Rescue included before Callbacks (#155), and a `:date` `in:` member written as a time-carrying string that is not midnight UTC (#153). An action can now receive something different: `nil` for an omitted nullable field with `default: ""` (#160), a `:datetime` such as `" +05:00"` read as the instant it names (#166), and an `ActionDispatch::Http::UploadedFile` where a `:file` field used to be refused (#165). An export now reads differently: a field the route fills from `defaults:` is no longer in the request body (#156), a nullable field's `default` prints as `null` (#160), and an operation with a `:file` field has only a `multipart/form-data` body (#165). And a spec can fail where it passed: a `reject_params(... nil ...)` that asserted a null array element on a Rails controller was a false green, because a real request never carries that null (#154).
9
+
10
+ ### Added
11
+ - **A `:file` field type, and `of: :file`, for uploads.** No field type accepted an upload: a multipart POST whose file was declared `:string`, `:json` or `of: :string` got a 422 `invalid_type`, and an undeclared upload was dropped under `unknown: :ignore`, so `Model.create!(permitted_params)` lost the attachment. A `:file` field takes the two upload objects strong parameters permit, `ActionDispatch::Http::UploadedFile` and `Rack::Test::UploadedFile`, passes them through uncast, and refuses everything else, a String included, as `invalid_type`. `of: :file` works the same way, with the blank-element drop of #111, and an empty file input still sends `""`, which is absent. `validate:` can check the upload (`->(f) { f.size <= 5.megabytes }`). `ColumnGuard` treats `:file` like `virtual: true`, since an attachment is not a column, and the matchers accept `Rack::Test::UploadedFile` payloads. An ActiveStorage direct upload (`direct_upload: true`) submits the blob's signed id, a String, in place of the file, so a `:file` field refuses it; declare that field `:string` and attach the signed id. (#165)
12
+ - **An operation with a `:file` field is exported as `multipart/form-data`.** A `:file` leaf is `{"type":"string","contentMediaType":"application/octet-stream"}`, the OpenAPI 3.1 spelling, with no `minLength` because a 0-byte upload is accepted. An operation whose rule has a `:file` field anywhere gets only a `multipart/form-data` body, with Rails' bracket names as properties (`avatar[image]`), an array of hashes described in the indexed `fields_for` form with anchored ECMA-262 `patternProperties`, and container constraints in a body-level `x-permittable-form-groups` map keyed by bracket name. Under `unknown: :error` the body gets `additionalProperties: false` at every depth, and still allows a nullable container's null part, a `:json` field's nested keys, parts outside a `root:` and, for a rootless rule, Rails' form keys (`Permittable::FORM_KEYS`); a renamed CSRF token and an image submit's `x` and `y` cannot be known statically, and the README says so. Every other operation exports byte-identically, and a JSON value for a file passes the exported schema while the server refuses it, which joins the README's list of divergences. (#165)
13
+ - **`OpenAPI.rails_routes` descriptors gain an optional `defaults:` key.** It is an array of name strings, present only when the route has `defaults:` other than `controller`, `action` and `format`. A mount's defaults are merged into each engine route the mount reaches. A caller that passes its own descriptors to the export can supply it; a descriptor without the key behaves as before. (#156)
14
+
15
+ ### Changed
16
+ - **A `:date` string with a time of day or a zone is accepted only at midnight UTC.** A `:date` kept the calendar day of a string and ignored the rest, so `"2026-02-28T25:00:00Z"` was valid and `"2026-02-28T23:30:00-05:00"`, which is 2026-03-01 in UTC, was read as 2026-02-28. Such a string is now read the way `:datetime` reads it (a Gregorian parse, a zoneless string as UTC, an impossible time or an offset Ruby cannot honour refused) and accepted only if the result is exactly midnight UTC; the value is a plain `Date` built from its year, month and day. `"2026-09-05T24:00:00Z"` now reads as 2026-09-06, the instant it names. `"1500-02-29"`, a Julian-only leap day, is still accepted as a plain date, but is refused once it carries a time, because a time-carrying string is read proleptic Gregorian. A `:date` `in:` member written as a time-carrying string fails at class load by the same rule. Date-only strings, `Date`, `Time`, `DateTime` and `TimeWithZone` objects, and `:datetime` are unchanged. (#153)
17
+ - **An ISO date followed by an offset or `Z` and no time is midnight at that offset; other bare offsets and `T` are refused.** With no time of day, `Date._parse` read a signed offset as the time and dropped its sign, so `:datetime` read both `"2026-09-05 +05:00"` and `"2026-09-05 -05:00"` as 05:00 UTC, and a `-0500` or `+5` was dropped and read as midnight UTC. A whole string that is an ISO date and a numeric offset or `Z` (`"2026-09-05+00:00"`, `"20260905Z"`) is now midnight at that offset, and the offset must be one Ruby honours (`+24:00`, `-99:00` and `+05:99` are refused). Any other date followed by a bare signed offset (`"Sep 5 2026 -05:00"`) is `invalid_type`, and so is an ISO date followed by `T` with no time, which is never read as a military zone. A `:date` applies the same rules with #153's midnight-UTC acceptance on top, so `"2026-09-05 -05:00"` and `"2026-09-05 +05:00"` are `invalid_type` for a `:date` and the instants they name for a `:datetime`. A zone name with no time (`"2026-09-05 EST"`) keeps its earlier reading. A plain ISO `:date` string skips the new checks and costs what it did; a `:date` string that carries a time costs more, because it is now read the way a `:datetime` is. (#166)
18
+ - **The matchers run a controller payload through Rails' deep munge.** With `perform_deep_munge = true`, Rails removes `nil` from every array before a controller reads `params`, so a real request never shows a null element. The matchers passed the payload unchanged, so `reject_params(tags: ["a", nil]).with_violation("tags[1]", :invalid_type)` passed for a request the app serves with a 200, `accept_params(tags: ["a", nil])` failed, and a null slipped past `length:` uncounted. For a controller subject, the payload now goes through `ActionDispatch::Request::Utils.normalize_encode_params` on a copy, with Symbol keys stringified and an `ActionController::Parameters` unwrapped with `to_unsafe_h`. An app that turned deep munge off still sees the null. A spec that asserted the null element on a Rails controller now fails, and it was a false green. A standalone `Contract` is unchanged: a null element is still `invalid_type`. This also resolves `ActionDispatch::Http::Utils` on Rails main. (#154)
19
+ - **A bare `ActionController::Metal` controller enforces its rules whichever order its modules are included, or raises at class load.** The `rescue_from` and `before_action` hooks were registered once, at include time, and only if the host already answered both. A Metal controller that included Permittable before `AbstractController::Callbacks` and `ActionController::Rescue` got neither: an `enforce: true` POST without a required field got 202 and ran the action, monitor mode emitted no `invalid_parameters.permittable` event, a lazy rule raised an unrescued `InvalidParameters`, and `permittable:audit` still reported the rules as covered. The hooks are now registered at the include and again at each `permit_params`, and the gem never registers one twice, so a documented `skip_before_action :enforce_params_contract` holds. Where the gem cannot register the hook, `permit_params` raises `ArgumentError` naming the missing module: an `enforce: true` or `mode: :monitor` rule with no `before_action` (`AbstractController::Callbacks`), an `enforce: true` rule with no `rescue_from` (`ActionController::Rescue`), or Rescue included before Callbacks, which would let a `before_action` rejection escape as a 500. `ActionController::Base`, `ActionController::API` and non-controller hosts are untouched. A Metal controller with no `render` now answers a rejection instead of raising `NoMethodError: render` in the gem's handler: the error envelope writes `status`, `content_type` and `response_body` itself, byte-identical to `render json:` for the envelope and problem+json, and a `render_error` keeps its precedence. (#155)
20
+ - **A name the route fills from `defaults:` leaves the OpenAPI request body.** Rails merges a route's `defaults:` into `request.path_parameters`, which win over the body and are exempt from `unknown: :error`, but the export documented the name as a required body field: `post "w/special", defaults: { kind: "x" }` published `kind` with its enum while the server answered 200 with `kind` `"x"` whatever the body said. A rootless field whose name the route fills from `defaults:` is now removed from the body, its `required` entry included, for any kind of field; under `unknown: :error` it stays as a free property that is never required. No path parameter is added, because the URL does not carry the value. Two routes that share a path and verb keep only the intersection of their defaults, so a name a plain route still needs stays in the body. A `root:` contract, `defaults: { format: :json }` and array, nested and `:json` kinds that a path template names are unchanged. (#156)
21
+ - **A blank default on a nullable field reads as `nil`.** On a `nullable:` scalar, `default: ""`, or a default that normalizes to `""` such as `" "` with `normalize: :strip`, was stored as the blank string, while a request that sends `""` reads as `nil`. An omitted `optional :note, :string, nullable: true, default: ""` now gets `nil`, the exported schema prints `"default": null`, and `example: ""` publishes `[null]` as the array path already did. An `in:`, `format:` or `:integer` field with a nullable `default: ""` used to fail at class load and now loads with `nil` as its default. `with_default("")` on a nullable field reads as `nil` in the matcher. A field with `transform:` still stores its default as authored, a non-nullable `default: ""` is unchanged, and a nullable `required: true` field with `default: ""` is still refused. (#160)
22
+
23
+ ### Development
24
+ - **Bump railties, activerecord, actionpack and activesupport 8.1.4 to 8.1.4.1, sqlite3 to 2.10.2 and rubocop to 1.92.2.** Development dependencies only; the gem's runtime requirements are unchanged. (#168, #169, #170, #171, #172)
25
+
3
26
  ## 0.13.1 (2026-11-04)
4
27
  <!-- title: contracts that cannot be exported are refused at class load, a Metal catch-all and a date-parts example export correctly, and a pre-1582 date reads as the day it names -->
5
28
 
data/README.md CHANGED
@@ -538,6 +538,8 @@ Validation is **lazy by default**: it runs on the first `permitted_params` call,
538
538
 
539
539
  In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
540
540
 
541
+ A bare `ActionController::Metal` controller has no callbacks and no `rescue_from` until it includes them. `include AbstractController::Callbacks` and then `include ActionController::Rescue` (the order `ActionController::Base` uses, so a rejection also covers the `before_action`), before or after `include Permittable`. The rendering modules (`AbstractController::Rendering`, `ActionController::Rendering`, `ActionController::Renderers::All`) are optional: without `ActionController::Rendering` the gem writes the error response itself, with the same status, content type and JSON body. An `enforce: true` or `mode: :monitor` rule raises `ArgumentError` at class load when `AbstractController::Callbacks` is missing. So does an `enforce: true` rule that is not in monitor mode when `ActionController::Rescue` is missing or was included before `AbstractController::Callbacks`. Both modules have to be in by the time of `permit_params`. A check registered at that first `permit_params` runs after any callback declared between the include and it (see [Per-request locales](#custom-error-messages-message)), and with `include Permittable` first, a `skip_before_action :enforce_params_contract` goes after the first `permit_params`, since the callback does not exist before it. On a bare `ActionController::Metal` controller that includes `ActionController::Rescue` after `include Permittable`, the gem registers its handler at the first `permit_params` instead, so there it is a catch-all declared after that `permit_params` that answers first, and a re-declaration goes after it.
542
+
541
543
  ### Declaring a contract
542
544
 
543
545
  ```ruby
@@ -596,6 +598,21 @@ end
596
598
  optional :metadata, :json, max_depth: 3, length: 0..32
597
599
  ```
598
600
 
601
+ An upload is a `:file` field. It takes the objects Rails' strong parameters permit as scalars (`ActionDispatch::Http::UploadedFile`, `Rack::Test::UploadedFile`), passes the object through uncast, and refuses anything else, a String included, as `invalid_type`. Size and content-type checks belong in `validate:`:
602
+
603
+ ```ruby
604
+ optional :avatar, :file, validate: ->(file) { file.size <= 2.megabytes && file.content_type.start_with?("image/") }
605
+ array :attachments, of: :file, length: 0..5
606
+ ```
607
+
608
+ `:file` takes `required:`, `nullable:`, `validate:`, `transform:`, `desc:`, `sensitive:`, `virtual:` and `message:`. `in:`, `length:`, `format:`, `normalize:`, `default:` and `example:` are refused at class load. A form's empty file input sends `""`, which is absent as everywhere else. The [drift guard](#the-schema-drift-guard) does not look for a column behind a `:file` field (an attachment is not a column), and the OpenAPI export documents an operation with a file as `multipart/form-data`, its properties named the way Rails reads the parts (`user[avatar]`, `docs[]`, `user[address][scan]`). An array of hashes is sent with an index (`items[0][photo]`, `items[0][name]`, `items[1][photo]`, …), as Rails' `fields_for` renders it, and the document describes those leaves with anchored `patternProperties`. What a flat body cannot carry, a container's own `required`, `minItems`/`maxItems`, `nullable`, `description` and custom-validation marker, is kept in the body's `x-permittable-form-groups`, keyed by the container's bracket name (`user[items]`), with `[]` standing for the index of a container inside an element (`user[items][][info]`). Send a nullable container's null as an empty part named by that bracket name, without `[]` (`user[docs]=""`; `user[docs][]=""` is an empty array). Under `unknown: :error` the multipart body sets `additionalProperties: false`, which refuses an undeclared part at any depth (`user[address][bogus]`, `user[items][0][bogus]`), and it lists the parts the server still takes: a nullable container's null as `{"const": ""}`, a `:json` field's keys (`user[extra][any]`) and, for a `root:` rule, any part outside the root, or for a rootless one, the form keys the runtime exempts (`authenticity_token`, `_method`, `utf8`, `commit`, `button`). A renamed CSRF token (`request_forgery_protection_token`) and an image submit's `x`/`y` cannot be known when the document is built, so they are not listed. The children of an optional hash are required only when the hash is sent (the group entry says the hash is optional), and `patternProperties` cannot mark a leaf of an array element required.
609
+
610
+ An ActiveStorage direct upload (`form.file_field :avatar, direct_upload: true`) sends the file from the browser straight to the storage service, and the form then submits only the blob's signed id, a String, which a `:file` field refuses as `invalid_type`. Declare such a field `:string` and attach the signed id (`record.avatar.attach(signed_id)`):
611
+
612
+ ```ruby
613
+ optional :avatar, :string # direct upload: a signed blob id
614
+ ```
615
+
599
616
  Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
600
617
 
601
618
  An array of hashes also accepts the shape Rails' `fields_for` sends for nested attributes: `user[addresses_attributes][0][city]` reaches the params as a Hash keyed `"0"`, `"1"`, … and is read as the records it encodes, in the order sent, exactly as strong params and `accepts_nested_attributes_for` read it. Only that shape qualifies: every key an integer and every value a hash.
@@ -678,11 +695,12 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
678
695
  | `:float` | a real `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "`, and any number outside a Float's range, as a `Numeric` or a string alike (`BigDecimal("1e-400")`, `"1e400"`), a `Complex` (even `Complex(1, 0)`), a `Numeric` that cannot be converted |
679
696
  | `:decimal` | a real `Numeric`, or a canonical numeric string → `BigDecimal`; a `Rational` whose decimal expansion is exact (`Rational(1, 2)`, `3r`) | Unparseable strings, `"1_000"`, `" 1.5 "`, a `Complex`, a `Rational` no decimal holds exactly (`Rational(1, 3)`), a `Numeric` that cannot be converted |
680
697
  | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
681
- | `:date` | `Date`; a `Time`, `DateTime` or `ActiveSupport::TimeWithZone` that is exactly midnight UTC, as that `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`), and any other instant — one with a time of day, or a midnight in another zone |
682
- | `: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"`) |
698
+ | `:date` | `Date`; a `Time`, `DateTime` or `ActiveSupport::TimeWithZone` that is exactly midnight UTC, as that `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`); a string with a time or zone only if it is exactly midnight UTC (`"2026-09-05T00:00:00Z"`) | Unparseable strings, **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`), and any other instant, as an object or a string — one with a time of day, or a midnight in another zone |
699
+ | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time (an ISO date with an offset or `Z` and no time, `"2026-09-05-05:00"`, is midnight at that offset) | Unparseable strings, any string without a complete date (`"10:30"`), any other date followed by a bare offset and no time of day (`"Sep 5 2026 -05:00"`), and an ISO date followed by `T` and no time (`"2026-09-05T"`) |
683
700
  | `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
701
+ | `:file` | An upload object (`ActionDispatch::Http::UploadedFile`, `Rack::Test::UploadedFile`), passed through uncast | A `String` (including a form's file field sent as text), numbers, hashes, arrays |
684
702
 
685
- **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.
703
+ **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. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time.
686
704
 
687
705
  **Strings in other encodings are inspected, never converted.** A String whose bytes are not valid in its **own** encoding (`"caf\xC3"` in UTF-8, a lone UTF-16 surrogate) is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. Any other String keeps its encoding: a `:string` value is handed back exactly as it arrived, so a controller using Rails' `skip_parameter_encoding` or `param_encoding` gets its binary or Shift_JIS text unchanged. The number, boolean and date types parse a UTF-8 **copy** of the text, so UTF-16 `"12"` casts to `12` for an `:integer`; when the text has no UTF-8 reading (a byte Windows-1252 leaves undefined) that is `invalid_type`. `normalize:` and `format:` work on the String in its own encoding. Where a normalizer cannot handle that encoding (`:squish` on UTF-16), the value is left as it is; where a `format:` pattern cannot be applied to it (a non-ASCII pattern against UTF-16 or binary bytes), that is a `format` violation. `in:` compares Strings as Ruby does, encoding included. A `:json` field's contents are not examined.
688
706
 
@@ -726,7 +744,7 @@ In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{
726
744
 
727
745
  ### Absence, defaults, and partial updates
728
746
 
729
- `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 `""`. The same goes for a `""` **element** of a scalar `array ..., of:`: it is dropped before casting and before `length:` counts, because `collection_check_boxes` and `select multiple: true` always send a hidden `""` so that unchecking everything still sends the key. A `null` element, which no form sends, is still `invalid_type`.
747
+ `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 `""`. The same goes for a `""` **element** of a scalar `array ..., of:`: it is dropped before casting and before `length:` counts, because `collection_check_boxes` and `select multiple: true` always send a hidden `""` so that unchecking everything still sends the key. A `null` element, which no form sends, never reaches a Rails controller's contract under the default `perform_deep_munge` (Rails drops it from every array first); on a standalone `Contract`, or with deep munge off, it is still `invalid_type`.
730
748
 
731
749
  That single rule produces the behaviour you want from a `PATCH`:
732
750
 
@@ -765,7 +783,7 @@ Three more readings worth knowing:
765
783
 
766
784
  - **`required` + `nullable`** is coherent, and means what it says in SQL: the client *must* state the field, and `null` is a legal statement. A missing key still violates.
767
785
  - **`default: nil`** — legal only on a nullable field — gives the `PUT` reading, where absence *also* means clear.
768
- - **On arrays and nested blocks**, `nullable:` applies to the array or object itself, never to its contents. `{ "tags": null }` yields `nil` (distinct from `[]`, which still gets length-checked); a null *element* inside `tags` is still `invalid_type`.
786
+ - **On arrays and nested blocks**, `nullable:` applies to the array or object itself, never to its contents. `{ "tags": null }` yields `nil` (distinct from `[]`, which still gets length-checked); a null *element* inside `tags` is still `invalid_type` wherever Rails has not already dropped it (a standalone `Contract`, or `perform_deep_munge` off).
769
787
 
770
788
  Exported [OpenAPI](#exporting-openapi-docs-that-cannot-drift) tells the truth about all of this: a nullable field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum`.
771
789
 
@@ -910,7 +928,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
910
928
 
911
929
  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.
912
930
 
913
- 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 the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. 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, and the export then documents that field as the path parameter's schema (`required :id, :integer, in: 1..1000` becomes `{"type":"integer","minimum":1,"maximum":1000}`) and leaves it out of the request body, `required` list included, so a body without `id` is valid. Only a rootless contract does this, and only for a name the route templates; a `root:` contract's `id` is a body field and stays one, and so does an `array`, nested or `:json` field, since no URL segment can be one. Under `unknown: :error` the body schema forbids undeclared keys, but every name the route templates stays in it as a property that takes any value and is never required, declared or not: `PATCH /users/1` with `{"id":1,"name":"x"}` is accepted, because the path parameter is exempt and the URL's `id` overwrites the body's. **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.
931
+ 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 the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. 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, and the export then documents that field as the path parameter's schema (`required :id, :integer, in: 1..1000` becomes `{"type":"integer","minimum":1,"maximum":1000}`) and leaves it out of the request body, `required` list included, so a body without `id` is valid. Only a rootless contract does this, and only for a name the route templates; a `root:` contract's `id` is a body field and stays one, and so does an `array`, nested or `:json` field, since no URL segment can be one. Under `unknown: :error` the body schema forbids undeclared keys, but every name the route templates stays in it as a property that takes any value and is never required, declared or not: `PATCH /users/1` with `{"id":1,"name":"x"}` is accepted, because the path parameter is exempt and the URL's `id` overwrites the body's. A name the route fills from `defaults:` (`post "w", to: "widgets#create", defaults: { kind: "x" }`, on the route, its `scope` or the `mount` it is reached through) is no body field either, since the router overwrites the body's value: the export leaves it out of the body, `required` list included, and under `unknown: :error` keeps it as that same free property, but declares no path parameter for it, because no URL carries it. `rails_routes` names such keys in each descriptor's `defaults:`, apart from `controller`, `action` and `format`. **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.
914
932
 
915
933
  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.
916
934
 
@@ -1410,7 +1428,7 @@ Both work on a controller class, a controller instance, or a [standalone `Contra
1410
1428
 
1411
1429
  The negated forms follow the same rules as `not_to permit_param`: `not_to accept_params(...)` passes only when the contract rejects the payload, and `not_to reject_params(...)` only when it accepts it. Neither takes a qualifier — `not_to accept_params(...).returning(x)` would pass both on a rejection and on an acceptance returning anything else, so it raises and names the positive form — and both fail, rather than passing, when no rule covers the action or the subject declares no contracts.
1412
1430
 
1413
- The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`.
1431
+ The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off); a standalone `Contract` gets the payload as given.
1414
1432
 
1415
1433
  ### Standalone contracts (no controller)
1416
1434
 
@@ -1501,7 +1519,7 @@ Where they legitimately differ, the spec names the reason and asserts the **dire
1501
1519
  - **`null` as absence.** The runtime reads `{"age": null}` as `{}` ([absence](#absence-defaults-and-partial-updates)); JSON Schema cannot express that, so `type: integer` rejects a null the server would accept and ignore. A [`nullable:`](#explicit-nulls-nullable) field is not this case — there the null is a value, the exported `type` widens to say so, and the two agree.
1502
1520
  - **Padding that normalizes away.** `normalize:` runs before the checks, so under `normalize: :squish` and `length: 3..10` the server accepts `" abcdefghij "` — ten characters once squished — while the docs reject its fourteen.
1503
1521
 
1504
- Six cases go the other way, and are worth knowing before you hand the document to a client. Each rule stays visible on its own field, and the spec asserts that as well as the direction:
1522
+ Seven cases go the other way, and are worth knowing before you hand the document to a client. Each rule stays visible on its own field, and the spec asserts that as well as the direction:
1505
1523
 
1506
1524
  - **Bounds JSON Schema has no keyword for.** A `:json` field's `max_depth:` is enforced by the server but cannot be written as a JSON Schema keyword, so the published document is **looser** there and an over-nested payload still earns a 422. The bound is not dropped — it is exported as `x-permittable-max-depth` — so a generator or linter that wants it can read it.
1507
1525
  - **`normalize:` runs before the checks.** The server validates the *normalized* string, and JSON Schema has no keyword for "transform, then check". With `required :name, :string, length: 3..10, normalize: :squish`, `" "` passes the docs' `minLength: 3` and then squishes to `""` — absent, so `missing` — and `" a "` passes them and squishes to `"a"`, which is too short. The step is exported as `x-permittable-normalize` — the preset's name (`"squish"`, `"email"`, …), which a client can apply before validating, or `true` for a custom proc.
@@ -1510,7 +1528,9 @@ Six cases go the other way, and are worth knowing before you hand the document t
1510
1528
  - **A Range of non-numbers.** `in: "a".."m"` has no `minimum`/`maximum` equivalent (those constrain numbers only) and rides along as `x-permittable-range` instead. A value outside it still passes the docs.
1511
1529
  - **Strings whose validity is a `format`.** `:decimal`, `:date` and `:datetime` are sent as strings, and what makes such a string valid is its `format` (`"decimal"`, `"date"`, `"date-time"`) — which draft 2020-12 treats as an annotation unless a validator opts into asserting it. So `"abc"` or `"NaN"` for a `:decimal` and `"2026-02-30"` for a `:date` pass most validators and fail the server's cast with `invalid_type`.
1512
1530
 
1513
- A `format:` regexp that does not translate to ECMA-262 is looser in the same way — it publishes as `x-permittable-pattern` rather than a `pattern` that would enforce something else, so a value it refuses still passes the docs — but it is not one of the six above: `spec/schema_conformance_spec.rb` does not yet assert a case for it, since the Ruby → ECMA-262 translation it would depend on is being reworked separately. A `finalize` block is looser too, and has no field to sit on: its `violate!` cross-field checks (`ends_on` before `starts_on`) run only once every field has passed, so a payload each field's schema accepts can still earn a 422. The rule's own object carries `x-permittable-finalize: true` to say the schema alone is not the whole verdict, and so does the operation, so the marker is still there when an operation has no request body to hold the schema. Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.
1531
+ A `:file` field is looser in a way no JSON body can show: the JSON export accepts any string for it (`{"user":{"avatar":"abc"}}`), while the server takes only an upload object and refuses every JSON value. The schema describes the multipart part, and the field keeps `contentMediaType` to say so. It is a seventh case, asserted in the spec as `:file_upload`.
1532
+
1533
+ A `format:` regexp that does not translate to ECMA-262 is looser in the same way — it publishes as `x-permittable-pattern` rather than a `pattern` that would enforce something else, so a value it refuses still passes the docs — but it is not one of the seven above: `spec/schema_conformance_spec.rb` does not yet assert a case for it, since the Ruby → ECMA-262 translation it would depend on is being reworked separately. A `finalize` block is looser too, and has no field to sit on: its `violate!` cross-field checks (`ends_on` before `starts_on`) run only once every field has passed, so a payload each field's schema accepts can still earn a 422. The rule's own object carries `x-permittable-finalize: true` to say the schema alone is not the whole verdict, and so does the operation, so the marker is still there when an operation has no request body to hold the schema. Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.
1514
1534
 
1515
1535
 
1516
1536
  <details>
@@ -1523,6 +1543,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1523
1543
  | `required` / `optional` | the object's `required:` array; required strings, and the string encoding of a required `:decimal`/`:date`/`:datetime`, also get `minLength: 1` (`""` is absent; a `nullable:` `:decimal`/`:date`/`:datetime` reads it as a null, so it gets none) |
1524
1544
  | `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
1525
1545
  | `:date` / `:datetime` | `string` + `format: date` / `date-time` |
1546
+ | `:file` | `string` + `contentMediaType: application/octet-stream` (the 3.1 spelling; `format: binary` is 3.0's). An operation whose contract has a `:file` field gets a `multipart/form-data` request body instead of `application/json` |
1526
1547
  | `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
1527
1548
  | `in:` list / numeric Range | `enum` of the cast members (a `:date`/`:datetime` member written as a String is published as written) / `minimum` + `maximum` (exclusive ends honoured). An `in:` object that only answers `include?` is flagged `x-permittable-custom-validation` |
1528
1549
  | `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
@@ -61,12 +61,29 @@ module Permittable
61
61
  # with html listed first). Without it this JSON body goes out as HTML.
62
62
  # A JSON type the response already carries (a registered
63
63
  # application/vnd.api+json, say) is the host's choice and is kept.
64
- options = { json: { success: false, error: error }, status: status }
65
- options[:content_type] = "application/json" unless json_media_type?(response_media_type(controller))
66
- controller.render(**options)
64
+ type = json_media_type?(response_media_type(controller)) ? nil : "application/json"
65
+ render_json(controller, { success: false, error: error }, status: status, content_type: type)
67
66
  end
68
67
  end
69
68
 
69
+ # `render json:`, or — for a bare ActionController::Metal that never
70
+ # included ActionController::Rendering, and so has no `render` — the same
71
+ # response written through Metal's own status=, content_type= and
72
+ # response_body=. `render json:` calls `to_json` on a Hash, so the bytes
73
+ # are the same. A controller with `render` always takes the first path.
74
+ def render_json(controller, payload, status:, content_type: nil)
75
+ if !controller.respond_to?(:render) && controller.respond_to?(:response_body=)
76
+ controller.status = status
77
+ controller.content_type = content_type if content_type
78
+ controller.response_body = [payload.to_json]
79
+ return
80
+ end
81
+
82
+ options = { json: payload, status: status }
83
+ options[:content_type] = content_type if content_type
84
+ controller.render(**options)
85
+ end
86
+
70
87
  # The media type (no parameters) the response already carries, or nil when
71
88
  # it has none yet or the host has no response to ask.
72
89
  def response_media_type(controller)
@@ -160,7 +177,7 @@ module Permittable
160
177
  instance = request_path(controller)
161
178
  problem[:instance] = instance if instance
162
179
  problem[:errors] = details if details && !details.empty?
163
- controller.render(json: problem, status: status, content_type: PROBLEM_MEDIA_TYPE)
180
+ render_json(controller, problem, status: status, content_type: PROBLEM_MEDIA_TYPE)
164
181
  end
165
182
 
166
183
  # RFC 9457: an absent `type` means "about:blank", so that is the honest
@@ -67,7 +67,12 @@ module Permittable
67
67
  decimal: { "type" => %w[string number], "format" => "decimal" },
68
68
  boolean: { "type" => "boolean" },
69
69
  date: { "type" => "string", "format" => "date" },
70
- datetime: { "type" => "string", "format" => "date-time" }
70
+ datetime: { "type" => "string", "format" => "date-time" },
71
+ # OpenAPI 3.1 / JSON Schema 2020-12 describe a file as a string with a
72
+ # contentMediaType (`format: binary` is the 3.0 spelling, which 3.1
73
+ # dropped). It rides in a multipart/form-data body, see
74
+ # OpenApi.body_media_type.
75
+ file: { "type" => "string", "contentMediaType" => "application/octet-stream" }
71
76
  }.freeze
72
77
 
73
78
  # Request-body schema for one rule from `permittable_contracts` /
@@ -359,7 +364,8 @@ module Permittable
359
364
  # takes. A nullable :string keeps the floor it has always had (stricter
360
365
  # than the server, the safe direction); the other types start without.
361
366
  def blank_floor?(field)
362
- field[:required] && (field[:type] == :string || !field[:nullable])
367
+ # A :file is no string at the wire: a 0-byte upload is accepted.
368
+ field[:required] && field[:type] != :file && (field[:type] == :string || !field[:nullable])
363
369
  end
364
370
 
365
371
  def apply_pattern!(schema, regexp)
@@ -201,12 +201,158 @@ module Permittable
201
201
  end
202
202
 
203
203
  def rule_request_body(rule)
204
+ media_type = body_media_type(rule)
205
+ schema = JsonSchema.rule(rule)
206
+ schema = form_schema(schema, rule) if media_type == "multipart/form-data"
204
207
  {
205
208
  "required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
206
- "content" => { "application/json" => { "schema" => JsonSchema.rule(rule) } }
209
+ "content" => { media_type => { "schema" => schema } }
207
210
  }
208
211
  end
209
212
 
213
+ # A multipart body is not one JSON object: each part is a form field, and
214
+ # Rails reads the part NAMES. `user[avatar]` is the avatar of the `user`
215
+ # hash and a repeated `docs[]` is an array, so the schema's properties
216
+ # carry those names, flat, instead of nesting `user` as a single part
217
+ # (which a client would send as one JSON part, unable to hold the file).
218
+ # An array of hashes is sent in the indexed form Rails' fields_for renders
219
+ # (`items[0][photo]`, `items[1][photo]`, which the runtime reads), so its
220
+ # leaves are `patternProperties` with an anchored pattern each. What a
221
+ # flat body cannot say, a container's own `required`, bounds, `nullable`,
222
+ # `desc:` and `validate:` marker, rides in `x-permittable-form-groups`,
223
+ # keyed by the container's bracket name.
224
+ #
225
+ # Under `unknown: :error` the flat body refuses every other name, which
226
+ # covers each nesting level at once (`user[address][bogus]` is no listed
227
+ # name). It must then also list what the server still accepts: a
228
+ # nullable container's null (`user[docs]=""`), the keys of a :json
229
+ # field (`user[extra][any]`), and, for a `root:` rule, any part outside
230
+ # the root, which the runtime never reads; a rootless rule instead gets
231
+ # the form keys (FORM_KEYS) the runtime exempts at the top level.
232
+ def form_schema(json_schema, rule)
233
+ root = rule[:root] && rule[:root].to_s
234
+ form = { properties: {}, patterns: {}, required: [], groups: {}, strict: rule[:unknown] == :error }
235
+ flatten_form(json_schema, [], true, form, skip_group: root)
236
+ if form[:strict] && root
237
+ form[:patterns]["^(?!#{form_escape(root)}(?:\\[|$))"] = {}
238
+ elsif form[:strict]
239
+ form[:patterns]["^(?:#{Permittable::FORM_KEYS.map { |key| form_escape(key) }.join('|')})$"] = {}
240
+ end
241
+ schema = { "type" => "object", "properties" => form[:properties] }
242
+ schema["patternProperties"] = form[:patterns] unless form[:patterns].empty?
243
+ schema["additionalProperties"] = false if form[:strict]
244
+ schema["required"] = form[:required] unless form[:required].empty?
245
+ schema["x-permittable-form-groups"] = form[:groups] unless form[:groups].empty?
246
+ schema["x-permittable-finalize"] = true if rule[:finalize]
247
+ schema
248
+ end
249
+
250
+ GROUP_KEYS = %w[minItems maxItems description x-permittable-custom-validation].freeze
251
+
252
+ # `path` is the part name as segments: a key, or :index for the number
253
+ # an array of hashes' element carries. A path with no :index is a plain
254
+ # property name; one with an :index becomes a pattern. `required` is
255
+ # relative to the element inside an array of hashes: patterns cannot
256
+ # carry it, but a group inside the element (`items[][info]`) can.
257
+ def flatten_form(schema, path, required, form, skip_group: nil)
258
+ if form_container?(schema, "object")
259
+ form_group!(schema, path, required, form, skip_group)
260
+ schema["properties"].each do |key, child|
261
+ flatten_form(child, path + [key], required && schema.fetch("required", []).include?(key), form,
262
+ skip_group: skip_group)
263
+ end
264
+ elsif form_container?(schema, "array") && form_container?(schema["items"], "object")
265
+ form_group!(schema, path, required, form, skip_group)
266
+ flatten_form(schema["items"], path + [:index], true, form, skip_group: skip_group)
267
+ else
268
+ if Array(schema["type"]).include?("array")
269
+ schema, required = form_list(schema, path, required, form)
270
+ path += [""]
271
+ end
272
+ form[:patterns]["#{form_pattern(path).delete_suffix('$')}\\["] = {} if form[:strict] && form_opaque?(schema)
273
+ form_leaf!(schema, path, required, form)
274
+ end
275
+ end
276
+
277
+ def form_opaque?(schema)
278
+ Array(schema["type"]).include?("object") && !schema.key?("properties")
279
+ end
280
+
281
+ # The part a nullable container's null is sent as: an empty string.
282
+ def form_null!(path, form)
283
+ form_leaf!({ "const" => "" }, path, false, form) if form[:strict]
284
+ end
285
+
286
+ # A nullable array's null is an empty part named without the `[]`
287
+ # (`docs=""`); `docs[]=""` is an empty array. So the `docs[]` leaf loses
288
+ # "null" and the group entry, under the name the null is sent as, says so,
289
+ # and carries `required` too: listing `docs[]` as required would refuse
290
+ # the null, which sends no `docs[]` part. Returns [leaf schema, required].
291
+ def form_list(schema, path, required, form)
292
+ types = Array(schema["type"])
293
+ return [schema, required] unless types.include?("null")
294
+
295
+ form[:groups][form_name(path.map { |k| k == :index ? "" : k })] = { "required" => required, "nullable" => true }
296
+ form_null!(path, form)
297
+ types -= ["null"]
298
+ [schema.merge("type" => types.one? ? types.first : types), false]
299
+ end
300
+
301
+ def form_leaf!(schema, path, required, form)
302
+ if path.include?(:index)
303
+ form[:patterns][form_pattern(path)] = schema
304
+ else
305
+ name = form_name(path)
306
+ form[:properties][name] = schema
307
+ form[:required] << name if required
308
+ end
309
+ end
310
+
311
+ def form_name(path)
312
+ path.each_with_index.map { |key, i| i.zero? ? key.to_s : "[#{key}]" }.join
313
+ end
314
+
315
+ def form_pattern(path)
316
+ body = path.each_with_index.map do |key, i|
317
+ text = key == :index ? '\\d+' : form_escape(key)
318
+ i.zero? ? text : "\\[#{text}\\]"
319
+ end
320
+ "^#{body.join}$"
321
+ end
322
+
323
+ def form_escape(key)
324
+ key.to_s.gsub(JsonSchema::EcmaPattern::SYNTAX_ESCAPE) { |c| "\\#{c}" }
325
+ end
326
+
327
+ def form_group!(schema, path, required, form, skip_group)
328
+ return if path.empty? || path == [skip_group] || path.last == :index
329
+
330
+ group = { "required" => required }
331
+ if Array(schema["type"]).include?("null")
332
+ group["nullable"] = true
333
+ form_null!(path, form)
334
+ end
335
+ GROUP_KEYS.each { |key| group[key] = schema[key] if schema.key?(key) }
336
+ form[:groups][form_name(path.map { |k| k == :index ? "" : k })] = group
337
+ end
338
+
339
+ def form_container?(schema, kind)
340
+ return false unless schema.is_a?(Hash) && Array(schema["type"]).include?(kind)
341
+
342
+ kind == "array" ? schema.key?("items") : schema["properties"].is_a?(Hash)
343
+ end
344
+
345
+ # An upload cannot travel in JSON, so a rule with a :file field anywhere
346
+ # in it describes a multipart/form-data body; every other rule stays
347
+ # application/json.
348
+ def body_media_type(rule)
349
+ rule[:fields].any? { |f| file_field?(f) } ? "multipart/form-data" : "application/json"
350
+ end
351
+
352
+ def file_field?(field)
353
+ field[:type] == :file || field[:of] == :file || (field[:fields] || []).any? { |f| file_field?(f) }
354
+ end
355
+
210
356
  # { action => operation } for every action the controller's contracts
211
357
  # cover, resolved through permit_rule_for so last-matching-rule-wins holds
212
358
  # in the documentation exactly as it does at request time.
@@ -319,7 +465,12 @@ module Permittable
319
465
  routes.select { |route| OPENAPI_VERBS.include?(verb_of(route)) }
320
466
  .group_by { |route| [route[:controller].to_s, route[:action].to_s] }
321
467
  .transform_values do |matching|
322
- matching.map { |route| [route[:path], verb_of(route), route[:template]] }.uniq
468
+ matching.group_by { |route| [route[:path], verb_of(route), route[:template]] }.map do |slot, same|
469
+ # Several routes can reach one slot (a constrained one ahead
470
+ # of a plain one): a name leaves the body only if every one
471
+ # of them fills it.
472
+ [*slot, same.map { |route| Array(route[:defaults]) }.reduce(:&)]
473
+ end
323
474
  end
324
475
  end
325
476
 
@@ -420,9 +571,9 @@ module Permittable
420
571
  slots << OperationSlot.new(holder, action, nil, operation["operationId"], owner) unless holder.key?(action)
421
572
  holder[action] = operation
422
573
  else
423
- free.each do |path, verb, template|
574
+ free.each do |path, verb, template, defaults|
424
575
  holder = (paths[path] ||= {})
425
- placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path)
576
+ placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path, defaults)
426
577
  holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
427
578
  slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
428
579
  end
@@ -544,14 +695,22 @@ module Permittable
544
695
  # `own` is the route's own spelling of the template, which differs
545
696
  # from `path` when collapse_shapes placed the route under another
546
697
  # route's spelling: the same shape, so the names pair up by position.
547
- def with_path_parameters(operation, path, rule, own = path)
698
+ #
699
+ # `defaults` names what the route fills from its `defaults:` (see
700
+ # route_defaults). The router merges those over the body like a
701
+ # templated name, so they leave the body the same way, but the URL does
702
+ # not carry them and they are no path parameter. A name the path
703
+ # templates is that parameter instead: `get "o(/:id)", defaults:
704
+ # { id: 5 }` is a parameter on `/o/{id}` and a default only on `/o`.
705
+ def with_path_parameters(operation, path, rule, own = path, defaults = [])
548
706
  # A name the path templates twice (a mount and an engine route both
549
707
  # name `:id`) is declared once: OpenAPI forbids two parameters with
550
708
  # one name and location.
551
709
  variables = path.scan(/\{(\w+)\}/).flatten.uniq
552
- return operation if variables.empty?
553
-
554
710
  own_names = own.scan(/\{(\w+)\}/).flatten
711
+ supplied = Array(defaults) - own_names
712
+ return operation if variables.empty? && supplied.empty?
713
+
555
714
  fields = path_fields(rule, path.scan(/\{(\w+)\}/).flatten.zip(own_names).uniq(&:first))
556
715
  parameters = variables.map do |name|
557
716
  schema = fields[name] ? path_schema(fields[name]) : { "type" => "string" }
@@ -561,8 +720,8 @@ module Permittable
561
720
  # it; emission stays deterministic either way.
562
721
  operation.each_with_object({}) do |(key, value), out|
563
722
  if key == "requestBody"
564
- out["parameters"] = parameters
565
- value = body_without(rule, fields.values, own_names)
723
+ out["parameters"] = parameters unless parameters.empty?
724
+ value = body_without(rule, fields.values + default_fields(rule, supplied), own_names + supplied)
566
725
  end
567
726
  out[key] = value unless value.nil?
568
727
  end
@@ -590,6 +749,16 @@ module Permittable
590
749
  end.to_h
591
750
  end
592
751
 
752
+ # The rootless contract's fields for the names the route's `defaults:`
753
+ # fill. Unlike a path parameter's, any kind of field goes: the router
754
+ # overwrites the body's value whatever its shape, and no parameter has to
755
+ # carry it.
756
+ def default_fields(rule, names)
757
+ return [] if rule[:root]
758
+
759
+ rule[:fields].select { |f| names.include?(f[:name].to_s) }
760
+ end
761
+
593
762
  # A moved field's schema as the path parameter documents it. The URL
594
763
  # always supplies the segment, so a `default:` never applies and the
595
764
  # value is never null: `nullable:` would only add "null" to the type.
@@ -610,7 +779,8 @@ module Permittable
610
779
  # produces, so the schema must not forbid it. Each name the route
611
780
  # templates therefore stays in the body as an unconstrained property,
612
781
  # any value and never required, since the value is read from the URL and
613
- # nothing in the body can make the request wrong. A rooted contract
782
+ # nothing in the body can make the request wrong. A name the route's
783
+ # `defaults:` fill is treated the same. A rooted contract
614
784
  # needs none of this: its wrapper object is permissive already.
615
785
  def body_without(rule, moved, templated)
616
786
  rest = rule[:fields] - moved
@@ -619,7 +789,7 @@ module Permittable
619
789
  body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
620
790
  return body unless rule[:unknown] == :error && !rule[:root]
621
791
 
622
- properties = body["content"]["application/json"]["schema"]["properties"]
792
+ properties = body["content"].values.first["schema"]["properties"]
623
793
  templated.each { |name| properties[name] ||= {} }
624
794
  body
625
795
  end
@@ -806,6 +976,9 @@ module Permittable
806
976
  if engines && (mounted = mounted_routes(route)) && visiting.none? { |above| above.equal?(mounted) }
807
977
  found = collect_routes(mounted, engines: engines, prefixes: mount_prefixes(prefixes, route), parents: Array(id),
808
978
  visiting: visiting, reachable: reachable && unconditional?(route))
979
+ # What the mount fills reaches the engine's requests too.
980
+ mount_defaults = route_defaults(route)
981
+ found = found.map { |d| d.merge(defaults: (mount_defaults + Array(d[:defaults])).uniq) } unless mount_defaults.empty?
809
982
  verbs = route.verb.to_s.downcase.split("|")
810
983
  next verbs.empty? ? found : found.select { |descriptor| verbs.include?(descriptor[:verb]) }
811
984
  end
@@ -818,6 +991,7 @@ module Permittable
818
991
  verb = ALL_VERBS if verb.empty?
819
992
 
820
993
  variants = optional_variants(route.path.spec, requirements, prefixes)
994
+ defaults = route_defaults(route)
821
995
  # Read once per route; drop_shadowed reads it, then strips it.
822
996
  unconditional = reachable && unconditional?(route)
823
997
  # One route can answer several verbs (`match via: [:patch, :put]`, and
@@ -829,12 +1003,25 @@ module Permittable
829
1003
  variants.map do |path, shadows|
830
1004
  descriptor = { controller: requirements[:controller], action: requirements[:action],
831
1005
  verb: single.downcase, path: path, route: id, unconditional: unconditional }
1006
+ descriptor = descriptor.merge(defaults: defaults) unless defaults.empty?
832
1007
  shadows.empty? ? descriptor : descriptor.merge(shadows: shadows)
833
1008
  end
834
1009
  end
835
1010
  end
836
1011
  end
837
1012
 
1013
+ # The names a route fills from its `defaults:` (`scope`, `resources` and
1014
+ # `mount` take them too), as strings. The router puts them into
1015
+ # path_parameters beside the URL's own, so the client cannot set them;
1016
+ # `controller`, `action` and `format` are the router's own keys and
1017
+ # are left out, as the runtime leaves them out of `unknown:`. A
1018
+ # hand-built route that cannot say fills nothing.
1019
+ def route_defaults(route)
1020
+ return [] unless route.respond_to?(:defaults)
1021
+
1022
+ route.defaults.keys.map(&:to_s) - %w[controller action format]
1023
+ end
1024
+
838
1025
  # The Prefix of each variant of a mount's path under each of `prefixes`,
839
1026
  # outermost mount first, and within a mount present before absent, which
840
1027
  # is the order Rails' regexp tries them in. A variant is shadowed by every
@@ -453,7 +453,11 @@ module Permittable
453
453
  # proc cannot rewrite the spec's own literal (the same reason
454
454
  # validate_authored_value! copies before normalizing).
455
455
  own = expected.is_a?(String) ? expected.dup : expected
456
- status, value = Coercion.cast(field[:type], Coercion.apply_normalize(field[:normalize], own))
456
+ normalized = Coercion.apply_normalize(field[:normalize], own)
457
+ # Absent on a nullable field is the nil a contract stores for it.
458
+ return nil if field[:nullable] && Coercion.absent_value?(normalized)
459
+
460
+ status, value = Coercion.cast(field[:type], normalized)
457
461
  status == :ok ? value : expected
458
462
  when :array
459
463
  return expected unless expected.is_a?(Array)
@@ -749,7 +753,7 @@ module Permittable
749
753
  mirror_csrf_key(host)
750
754
  host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
751
755
  instance = host.new
752
- instance.params = @params
756
+ instance.params = request_params
753
757
  violations = instance.permittable_violations(action)
754
758
  result = violations.empty? ? instance.permitted_params(action) : nil
755
759
  [violations, result]
@@ -772,6 +776,52 @@ module Permittable
772
776
  host.define_method(:permittable_action_name) { action }
773
777
  end
774
778
 
779
+ # What a controller reads as `params`: the payload after the
780
+ # normalization every request goes through, ActionDispatch's Utils.
781
+ # Under the default perform_deep_munge it removes nil
782
+ # from every array, so a JSON `{"tags": ["a", null]}` reaches the
783
+ # contract as ["a"], and `length:` never counts the null. Handing the
784
+ # raw payload over made reject_params(tags: ["a", nil]) pass for a
785
+ # request that is a 200. An app that turned perform_deep_munge off
786
+ # still sees the null, because the setting is read, not assumed.
787
+ # A copy goes in: the normalizer rewrites arrays in place, and the
788
+ # payload belongs to the spec. The copy stringifies Symbol keys, which
789
+ # the normalizer's HashWithIndifferentAccess does anyway but only after
790
+ # it has read a Symbol :tempfile key as an upload, a key a request
791
+ # (String keys only) never has. A Parameters payload is unwrapped first
792
+ # so it is normalized like the Hash it wraps.
793
+ def request_params
794
+ utils = request_utils
795
+ return @params unless utils
796
+
797
+ payload = @params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params
798
+ utils.normalize_encode_params(copy_payload(payload))
799
+ end
800
+
801
+ # Rails main moved the class from ActionDispatch::Request::Utils to
802
+ # ActionDispatch::Http::Utils. nil only when ActionDispatch is not
803
+ # loaded at all (a non-Rails stand-in controller); once it is, a rename
804
+ # this does not know raises instead of silently skipping the
805
+ # normalization, which would bring the false greens back.
806
+ def request_utils
807
+ return unless defined?(ActionDispatch)
808
+
809
+ if defined?(ActionDispatch::Http::Utils) then ActionDispatch::Http::Utils
810
+ elsif defined?(ActionDispatch::Request::Utils) then ActionDispatch::Request::Utils
811
+ else
812
+ raise "Permittable's matchers cannot find Rails' parameter normalizer: neither " \
813
+ "ActionDispatch::Http::Utils nor ActionDispatch::Request::Utils is defined"
814
+ end
815
+ end
816
+
817
+ def copy_payload(value)
818
+ case value
819
+ when Hash then value.to_h { |key, item| [key.is_a?(Symbol) ? key.to_s : key, copy_payload(item)] }
820
+ when Array then value.map { |item| copy_payload(item) }
821
+ else value
822
+ end
823
+ end
824
+
775
825
  def run_contract
776
826
  result = @subject.call(@params)
777
827
  [result.violations, result.params]
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.13.1".freeze
2
+ VERSION = "0.14.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -218,7 +218,11 @@ module Permittable
218
218
  extend ActiveSupport::Concern
219
219
 
220
220
  LABEL = "Permittable".freeze
221
- SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
221
+ SCALAR_TYPES = %i[string integer float decimal boolean date datetime file].freeze
222
+ # An upload, as multipart params carry it. It is a scalar in strong params'
223
+ # sense (an upload object is permitted), but it is never cast, and a
224
+ # request's String can never be one.
225
+ FILE_TYPE = :file
222
226
  # Not a scalar: an opaque hash whose shape is deliberately undeclared, for
223
227
  # the json/jsonb column a contract has to be able to carry.
224
228
  JSON_TYPE = :json
@@ -640,15 +644,14 @@ module Permittable
640
644
 
641
645
  included do
642
646
  class_attribute :permittable_contracts, instance_accessor: false, default: []
643
-
644
- rescue_from InvalidParameters, with: :render_invalid_parameters if respond_to?(:rescue_from)
645
- # Registered here, at the include, and not at the first `permit_params`:
646
- # the README documents `skip_before_action :enforce_params_contract`, and
647
- # Rails raises when it is asked to skip a callback that is not defined
648
- # yet. The cost is the order: this check runs before any callback
649
- # declared after the include (see the README's "Per-request locales"),
650
- # so a locale switch that must cover it is a `prepend_around_action`.
651
- before_action :enforce_params_contract if respond_to?(:before_action)
647
+ # Whether the gem itself ever registered its rescue_from / before_action
648
+ # for this class (a subclass inherits the answer). Asking the callback
649
+ # chain instead would bring back a check the app took out with
650
+ # `skip_before_action :enforce_params_contract`.
651
+ class_attribute :permittable_rescue_registered, :permittable_callback_registered,
652
+ instance_accessor: false, default: false
653
+
654
+ permittable_register_hooks
652
655
  end
653
656
 
654
657
  # Strict params-shaped coercion, shared by request-time validation and
@@ -746,6 +749,7 @@ module Permittable
746
749
  end
747
750
 
748
751
  def cast(type, value)
752
+ return cast_file(value) if type == FILE_TYPE
749
753
  return [:error, "invalid_type"] unless scalar_shaped?(value)
750
754
  return public_send("cast_#{type}", value) unless value.is_a?(String)
751
755
  # Bytes that are not valid in the String's OWN encoding are not text in
@@ -819,6 +823,17 @@ module Permittable
819
823
  value.dup.force_encoding(Encoding::UTF_8).scrub
820
824
  end
821
825
 
826
+ # Exactly the upload objects ActionController::Parameters permits as
827
+ # scalars (its PERMITTED_SCALAR_TYPES): what Rails' multipart parser
828
+ # builds, and what rack-test sends. Each constant is looked up at call
829
+ # time, so the gem loads and a standalone Contract works without
830
+ # ActionDispatch or rack-test. The object is handed back as it came.
831
+ def cast_file(value)
832
+ upload = (defined?(ActionDispatch::Http::UploadedFile) && value.is_a?(ActionDispatch::Http::UploadedFile)) ||
833
+ (defined?(Rack::Test::UploadedFile) && value.is_a?(Rack::Test::UploadedFile))
834
+ upload ? [:ok, value] : [:error, "invalid_type"]
835
+ end
836
+
822
837
  # Arrays, hashes, and nested ActionController::Parameters
823
838
  # (`?age[]=1`, `?age[x]=1`) can never satisfy a scalar type.
824
839
  def scalar_shaped?(value)
@@ -1018,8 +1033,17 @@ module Permittable
1018
1033
  date ? [:ok, date] : [:error, "invalid_type"]
1019
1034
  when Date then [:ok, value]
1020
1035
  when String
1036
+ # A bare ISO date ("2026-09-05", the common case) carries no zone, T
1037
+ # or offset, so it skips every check for one.
1038
+ plain = PLAIN_ISO_DATE.match?(value)
1039
+ return cast_date_instant(value) if !plain && (ISO_DATE_AT_OFFSET.match?(value) || ISO_DATE_T.match?(value))
1040
+
1021
1041
  found = Date._parse(value)
1022
1042
  return [:error, "invalid_type"] unless complete_date?(found)
1043
+ return cast_date_instant(value, found) if names_an_instant?(found)
1044
+ # No time found, but an offset may have been dropped ("5/9/2026
1045
+ # -0500"), which :datetime refuses too.
1046
+ return [:error, "invalid_type"] if !plain && offset_misread?(value, found)
1023
1047
 
1024
1048
  # Built from the components rather than re-running Date.parse, which
1025
1049
  # would parse the same string a second time — and Date.parse is the
@@ -1038,6 +1062,32 @@ module Permittable
1038
1062
  found.key?(:year) && found.key?(:mon) && found.key?(:mday)
1039
1063
  end
1040
1064
 
1065
+ # A time of day or a zone, as opposed to a bare calendar date.
1066
+ def names_an_instant?(found)
1067
+ %i[hour min sec sec_fraction zone offset].any? { |part| found.key?(part) }
1068
+ end
1069
+
1070
+ # A String that carries a time or a zone is the instant it names, read
1071
+ # exactly as :datetime reads it (a zoneless one as UTC, an impossible
1072
+ # time or an offset Ruby cannot honour refused) and then as a Time is
1073
+ # read: a date only at midnight UTC. Keeping just the calendar day
1074
+ # accepted "T25:00:00Z" and "+24:00", and turned "T23:30:00-05:00",
1075
+ # which is the next day in UTC, into the day before.
1076
+ #
1077
+ # The Date is built from the components, like a date-only String's,
1078
+ # because Time#to_date reads the proleptic Gregorian day in the Julian
1079
+ # calendar before 1582 ("1500-01-01T00:00:00Z" became 1499-12-23).
1080
+ #
1081
+ # `found` is the String's Date._parse, when the caller has it and has
1082
+ # ruled out the ISO forms cast_datetime reads without one; :datetime's
1083
+ # reading of it is then the same, with one parse fewer.
1084
+ def cast_date_instant(value, found = nil)
1085
+ status, instant = found ? cast_parsed_datetime(value, found) : cast_datetime(value)
1086
+ return [:error, "invalid_type"] unless status == :ok && instant == instant.beginning_of_day
1087
+
1088
+ [:ok, Date.new(instant.year, instant.month, instant.day)]
1089
+ end
1090
+
1041
1091
  # The date a Time or DateTime IS, or nil: its UTC date when it is
1042
1092
  # exactly midnight UTC. That is the instant ActiveSupport compares a
1043
1093
  # Date as, and the one :datetime reads a Date as, so the two types map
@@ -1102,16 +1152,28 @@ module Permittable
1102
1152
  # converts by day number, so "1500-01-01" became 1500-01-10 — unless
1103
1153
  # the host had loaded ActiveSupport's DateTime#to_time, which
1104
1154
  # converts by component. Gregorian, both agree.
1105
- found = Date._parse(value)
1106
- return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
1155
+ if (iso = ISO_DATE_AT_OFFSET.match(value)) then return midnight_at_offset(*iso.captures)
1156
+ elsif ISO_DATE_T.match?(value) then return [:error, "invalid_type"]
1157
+ end
1107
1158
 
1108
- [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1159
+ cast_parsed_datetime(value, Date._parse(value))
1109
1160
  else [:error, "invalid_type"]
1110
1161
  end
1111
1162
  rescue ArgumentError, RangeError
1112
1163
  [:error, "invalid_type"]
1113
1164
  end
1114
1165
 
1166
+ # cast_datetime's reading of a String once Date._parse has `found` its
1167
+ # parts, shared with cast_date_instant so a :date need not parse twice.
1168
+ def cast_parsed_datetime(value, found)
1169
+ return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
1170
+ return [:error, "invalid_type"] if offset_misread?(value, found)
1171
+
1172
+ [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1173
+ rescue ArgumentError, RangeError
1174
+ [:error, "invalid_type"]
1175
+ end
1176
+
1115
1177
  # A zone the parser could not turn into an offset — an offset of a day
1116
1178
  # or more ("+24:00", "-99:00", "+99") or an abbreviation it does not
1117
1179
  # know ("XYZ") — is reported as a zone with no offset, or with one
@@ -1125,6 +1187,74 @@ module Permittable
1125
1187
  !offset.nil? && offset.abs < 86_400
1126
1188
  end
1127
1189
 
1190
+ # An ISO date followed by an offset or Z and no time of day
1191
+ # ("2026-09-05-05:00", "2026-09-05 +05:00", "20260905Z"): XSD xs:date and
1192
+ # Java's ISO_DATE, for midnight at that offset. Date._parse does not read
1193
+ # it so — it takes "-05:00" for the TIME 05:00 and drops the sign, or
1194
+ # drops "-0500" altogether — so the instant is built from the captures.
1195
+ # Only the whole String counts, so a plain "2026-09-05" (which ends in
1196
+ # "-05") never matches, and costs nothing more than the match.
1197
+ ISO_DATE_AT_OFFSET = /\A\s*(\d{4})(-?)(\d{2})\2(\d{2})\s*(Z|z|[+-]\d{2}(?::?\d{2})?)\s*\z/
1198
+
1199
+ # An ISO date followed by a "T" and no time ("2026-09-05T",
1200
+ # "20260905T-0500"). Ruby reads a lone letter as a military zone, T as
1201
+ # -07:00, or drops it with whatever follows, so it is refused rather
1202
+ # than read as some instant the client did not write.
1203
+ ISO_DATE_T = /\A\s*\d{4}(-?)\d{2}\1\d{2}[Tt](?!\s*\d)/
1204
+
1205
+ # An ISO date and nothing else, which none of the above can match.
1206
+ PLAIN_ISO_DATE = /\A\s*\d{4}-?\d{2}-?\d{2}\s*\z/
1207
+
1208
+ # A signed number at the end of a String, and whether a space sets it
1209
+ # apart from what comes before ("5/9/2026 -0500" but not "2026-09-05").
1210
+ SIGNED_TAIL = /(\s)?[+-]\d+(?::\d+)*\s*\z/
1211
+ private_constant :ISO_DATE_AT_OFFSET, :ISO_DATE_T, :PLAIN_ISO_DATE, :SIGNED_TAIL
1212
+
1213
+ # The captures of ISO_DATE_AT_OFFSET as the instant they name, held to
1214
+ # the rule any other offset is (honoured_zone?): "+24:00" and "-99:00"
1215
+ # are refused, and so are minutes past 59 ("+05:99"), which the parser
1216
+ # reports with no offset. Date.new checks the day ("2026-02-30Z"), which
1217
+ # Time.utc would roll over into March.
1218
+ def midnight_at_offset(year, _dash, mon, mday, zone)
1219
+ year, mon, mday = [year, mon, mday].map { |part| Integer(part, 10) }
1220
+ offset = zone_offset(zone)
1221
+ return [:error, "invalid_type"] unless honoured_zone?(zone: zone, offset: offset)
1222
+
1223
+ Date.new(year, mon, mday, Date::GREGORIAN)
1224
+ [:ok, Time.utc(year, mon, mday) - offset]
1225
+ end
1226
+
1227
+ # "Z", "+05", "-0530" or "+05:30" in seconds east of UTC, or nil when
1228
+ # the minutes are not minutes.
1229
+ def zone_offset(zone)
1230
+ return 0 if zone.casecmp?("z")
1231
+
1232
+ hours = Integer(zone[1, 2], 10)
1233
+ minutes = Integer(zone[3..].delete(":").rjust(1, "0"), 10)
1234
+ return if minutes > 59
1235
+
1236
+ (zone.start_with?("-") ? -1 : 1) * ((hours * 3600) + (minutes * 60))
1237
+ end
1238
+
1239
+ # Whether a date's trailing offset was taken for its time of day
1240
+ # ("Sep 5 2026 -05:00" reports 05:00, "2026-09-05 UTC-05:00" too) or
1241
+ # dropped without a trace ("5/9/2026 -0500", "2026-09-05 +5"), outside
1242
+ # the ISO form midnight_at_offset reads. The tail is the culprit when the
1243
+ # parser found no zone and the String without it names the same date and
1244
+ # no time. Only a String that ends in a signed number and either got an
1245
+ # hour or has a space before the number is parsed again, so a plain
1246
+ # "2026-09-05" is not.
1247
+ def offset_misread?(value, found)
1248
+ return false if found.key?(:zone)
1249
+
1250
+ tail = SIGNED_TAIL.match(value) or return false
1251
+ return false unless tail[1] || found.key?(:hour)
1252
+
1253
+ rest = Date._parse(tail.pre_match)
1254
+ complete_date?(rest) && !rest.key?(:hour) &&
1255
+ rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
1256
+ end
1257
+
1128
1258
  # Presets only make sense on String input; a non-String value (JSON
1129
1259
  # numbers, booleans) skips normalization and goes straight to the cast.
1130
1260
  #
@@ -1342,6 +1472,7 @@ module Permittable
1342
1472
  class ContractBuilder
1343
1473
  SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
1344
1474
  nullable].freeze
1475
+ FILE_REFUSED_OPTS = %i[in format length default normalize example].freeze
1345
1476
  NESTED_OPTS = %i[virtual sensitive message desc nullable].freeze
1346
1477
  JSON_OPTS = %i[length max_depth default validate virtual sensitive transform message desc example
1347
1478
  nullable].freeze
@@ -1393,6 +1524,7 @@ module Permittable
1393
1524
  name = field_name!(name)
1394
1525
  assert_opts!(name, opts, ARRAY_OPTS)
1395
1526
  required = opts.delete(:required) ? true : false
1527
+ refuse_file_opts!(name, opts.keys & %i[default example], FILE_TYPE) if opts[:of]&.to_sym == FILE_TYPE
1396
1528
 
1397
1529
  field = { name: name, kind: :array, required: required, **opts }
1398
1530
  if field[:required] && field.key?(:default)
@@ -1598,6 +1730,7 @@ module Permittable
1598
1730
 
1599
1731
  def validate_scalar_opts!(field)
1600
1732
  name = field[:name]
1733
+ refuse_file_opts!(name, field.keys & FILE_REFUSED_OPTS, FILE_TYPE) if field[:type] == FILE_TYPE
1601
1734
  if field[:required] && field.key?(:default)
1602
1735
  raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
1603
1736
  end
@@ -1773,6 +1906,17 @@ module Permittable
1773
1906
 
1774
1907
  # format / length / normalize reason about characters; on any other
1775
1908
  # type they would silently apply to a cast non-String and mislead.
1909
+ # An upload is passed through as it came, so what shapes a String or
1910
+ # picks a value to stand in for one has nothing to act on. `validate:`
1911
+ # is where a size or content-type check belongs.
1912
+ def refuse_file_opts!(name, opts, type)
1913
+ opt = opts.first
1914
+ return unless opt
1915
+
1916
+ raise ArgumentError, "#{LABEL}: :#{opt} is not supported on :#{type} fields (field :#{name} is :#{type}); " \
1917
+ "use validate: for a size or content-type check"
1918
+ end
1919
+
1776
1920
  def validate_string_only_opts!(field)
1777
1921
  return if field[:type] == :string
1778
1922
 
@@ -1930,9 +2074,17 @@ module Permittable
1930
2074
  # proc cannot rewrite the host's own literal.
1931
2075
  authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
1932
2076
  value = Coercion.apply_normalize(field[:normalize], authored)
1933
- status, result = Coercion.refuse_finer_than_nanosecond(
1934
- field[:type], Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
1935
- )
2077
+ # The walker's absence rule: on a nullable field a value that reads as
2078
+ # absent ("" or one normalizing to "") is the explicit nil a request
2079
+ # sending it gets, with nothing to cast or check — as an array's
2080
+ # nullable sub-field already reads it (see AuthoredValues).
2081
+ status, result = if field[:nullable] && Coercion.absent_value?(value)
2082
+ [:ok, nil]
2083
+ else
2084
+ Coercion.refuse_finer_than_nanosecond(
2085
+ field[:type], Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
2086
+ )
2087
+ end
1936
2088
  raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
1937
2089
 
1938
2090
  field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
@@ -2153,6 +2305,8 @@ module Permittable
2153
2305
  rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
2154
2306
  model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
2155
2307
  finalize: builder.finalizer, desc: desc&.deep_dup&.freeze }.freeze
2308
+ permittable_register_hooks
2309
+ permittable_ensure_runnable!(rule)
2156
2310
  self.permittable_contracts = permittable_contracts + [rule]
2157
2311
  end
2158
2312
 
@@ -2166,6 +2320,69 @@ module Permittable
2166
2320
 
2167
2321
  private
2168
2322
 
2323
+ # Registers the rescue_from handler and the before_action the gem relies
2324
+ # on, each once per class, as soon as the class can take them. They used
2325
+ # to be registered at the include alone, so a bare ActionController::Metal
2326
+ # controller that included Permittable BEFORE AbstractController::Callbacks
2327
+ # and ActionController::Rescue never got them: an `enforce: true` rule let
2328
+ # bad requests through, and a violation escaped as a 500. Called at the
2329
+ # include and again at every permit_params, which is where such a class
2330
+ # has by now been given its modules. The callback is still registered
2331
+ # ahead of any declared after this point (see the README's "Per-request
2332
+ # locales").
2333
+ def permittable_register_hooks
2334
+ if !permittable_rescue_registered && respond_to?(:rescue_from)
2335
+ rescue_from InvalidParameters, with: :render_invalid_parameters
2336
+ self.permittable_rescue_registered = true
2337
+ end
2338
+ return if permittable_callback_registered || !respond_to?(:before_action)
2339
+
2340
+ # Registered at the include, and not at the first `permit_params`, when
2341
+ # the host has callbacks by then: the README documents
2342
+ # `skip_before_action :enforce_params_contract`, and Rails raises when it
2343
+ # is asked to skip a callback that is not defined yet. The cost is the
2344
+ # order: this check runs before any callback declared after the include,
2345
+ # so a locale switch that must cover it is a `prepend_around_action`.
2346
+ before_action :enforce_params_contract
2347
+ self.permittable_callback_registered = true
2348
+ end
2349
+
2350
+ # A bare ActionController::Metal controller has neither callbacks nor
2351
+ # rescue_from until the app includes them, and a rule that needs one of
2352
+ # them would otherwise load and then not run: raise at class load, naming
2353
+ # the module. Other hosts (a plain class, a duck) are left as they are.
2354
+ def permittable_ensure_runnable!(rule)
2355
+ return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
2356
+
2357
+ monitored = rule[:mode] == :monitor
2358
+ missing = []
2359
+ missing << "AbstractController::Callbacks" if (rule[:enforce] || monitored) && !respond_to?(:before_action)
2360
+ missing << "ActionController::Rescue" if rule[:enforce] && !monitored && !respond_to?(:rescue_from)
2361
+ if missing.empty?
2362
+ return unless rule[:enforce] && !monitored && permittable_rescue_inside_callbacks?
2363
+
2364
+ raise ArgumentError,
2365
+ "#{LABEL}: permit_params on #{name || inspect}, an ActionController::Metal controller, cannot rescue " \
2366
+ "this enforce: true rule: include ActionController::Rescue after AbstractController::Callbacks, " \
2367
+ "as ActionController::Base does. Included before it, Rescue wraps only the action, so the " \
2368
+ "before_action's InvalidParameters escapes as a 500"
2369
+ end
2370
+
2371
+ raise ArgumentError,
2372
+ "#{LABEL}: permit_params on #{name || inspect}, an ActionController::Metal controller, cannot run " \
2373
+ "this #{monitored ? 'monitor-mode' : 'enforce: true'} rule: include #{missing.join(' and ')} " \
2374
+ "before permit_params (ActionController::Rescue after AbstractController::Callbacks, as ActionController::Base does)"
2375
+ end
2376
+
2377
+ # A module included later sits earlier in the ancestors and so runs
2378
+ # outside the others' process_action: Rescue is only around the
2379
+ # callbacks when it was included after them.
2380
+ def permittable_rescue_inside_callbacks?
2381
+ rescue_at = ancestors.index(::ActionController::Rescue)
2382
+ callbacks_at = ancestors.index(::AbstractController::Callbacks)
2383
+ !rescue_at.nil? && !callbacks_at.nil? && rescue_at > callbacks_at
2384
+ end
2385
+
2169
2386
  def resolve_permit_model(model)
2170
2387
  case model
2171
2388
  when nil, false then nil
@@ -2193,8 +2410,12 @@ module Permittable
2193
2410
  # The drift guard. Nested/array fields are implicitly virtual — only
2194
2411
  # scalar fields, and the opaque `:json` field standing in for a
2195
2412
  # json/jsonb column, map one-to-one onto columns.
2413
+ #
2414
+ # A :file field is an upload, which maps to an attachment (ActiveStorage's
2415
+ # has_one_attached, Shrine) rather than a column, so it is never demanded
2416
+ # a column, as if it were virtual: true.
2196
2417
  def guard_contract_columns!(model_class, fields)
2197
- checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] }
2418
+ checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] && f[:type] != FILE_TYPE }
2198
2419
  return if checked.empty?
2199
2420
 
2200
2421
  types = checked.to_h { |f| [f[:name], f[:type]] }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-04 00:00:00.000000000 Z
11
+ date: 2026-11-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport