permittable 0.13.0 → 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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +40 -11
- data/lib/permittable/authored_values.rb +37 -1
- data/lib/permittable/column_guard.rb +33 -0
- data/lib/permittable/error_envelope.rb +21 -4
- data/lib/permittable/json_schema.rb +8 -2
- data/lib/permittable/open_api.rb +248 -12
- data/lib/permittable/rspec.rb +52 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +301 -22
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a884eb52cc555157fddf46346de7995e12c1da2920e8488cfae7d7f383123824
|
|
4
|
+
data.tar.gz: dae0d105fb44f1a0dac9cc07b33fd583e3e5fc5952f5511bb46126a56ae229ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63a2d785b2067d7d581d13775da06c180db170597acaad22a9c3de0622a7592f79a7ccf8febcb6199a53954ac03bfa606e781110927e02834145c313dcb6b366
|
|
7
|
+
data.tar.gz: baca9f1599f2637e68ab407bb81861d342fc79b43c6a836aa6e165e47acf539c6f3625fcdc07cfe4c5dd9c9e1b1e69de75df481dffc7d95126e0ae3f52ac383d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
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
|
+
|
|
26
|
+
## 0.13.1 (2026-11-04)
|
|
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 -->
|
|
28
|
+
|
|
29
|
+
Six fixes, one documentation change and a development dependency bump, no new API. A `:datetime` `default:` or `example:` finer than a nanosecond and a `:json` `default:` or `example:` holding NaN or Infinity are now refused at class load, instead of loading and then breaking the export. The OpenAPI export of a catch-all contract on an `ActionController::Metal` controller no longer documents the framework's own methods as operations, and an array `example:` written with date parts is exported as the date it spells. A midnight-UTC `Time` or `DateTime` sent to a `:date` before 1582 is read as the day it names. A missing-column error for an ignored column no longer suggests a migration, and the README now says that a catch-all `rescue_from` declared after the include wins.
|
|
30
|
+
|
|
31
|
+
Patch rather than minor: nothing that works today stops working. Every entry is a crash or a wrong value on input that was already mishandled, an export correction that makes the document agree with what the server does, a clearer error message, documentation, or development tooling. Four things are visible from outside and worth knowing before you upgrade. A contract now fails at class load, naming the field, in three cases: its `:datetime` `default:` or `example:` is finer than a nanosecond (#159), which the export published as a different instant; its `:json` `default:` or `example:` holds NaN or Infinity (#163), which made the export raise; or its `:date` `in:` member is a `Time` on one of the days 1582-10-05 to 1582-10-14 (#164), which was read silently as 1582-09-30. The OpenAPI export of a Metal catch-all loses its bogus framework operations (#161). An array `example:` written with date parts is now exported correctly (#162).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **An ignored column that is in the table was reported as missing, with a migration hint that would add it twice.** `ColumnGuard` checked a field against `column_names`, which leaves out `ignored_columns`, so `optional :legacy, :integer` on a model with `self.ignored_columns = %w[legacy]` raised "does not exist in the database" and suggested `bin/rails generate migration`. The guard still refuses the field, since assigning an ignored column raises `ActiveModel::UnknownAttributeError`, but the message now says the model ignores the column and suggests removing it from `ignored_columns` or declaring the field `virtual: true`. When the column was dropped and its name was left in `ignored_columns`, the message says the table has no such column and gives no migration. (#157)
|
|
35
|
+
- **A `:datetime` `default:` or `example:` finer than a nanosecond loaded, and the export published a different instant.** 0.12.0 refused such an `in:` member (#106), but `Time.at(Rational(1, 3)).utc` as a `default:` was exported as `.333333333Z`, so a client that sent the published default sent a different instant. A `default:` or `example:` finer than a nanosecond now raises `ArgumentError` at class load, naming the field, at any depth: a scalar, an `of: :datetime` array, an array of hashes and a sub-field. A String with ten or more fractional digits is refused too, unlike an `in:` String member, which is published as written. A nanosecond-exact value is still accepted. (#159)
|
|
36
|
+
- **The OpenAPI export documented about 36 framework methods of a Metal controller as operations.** A catch-all `permit_params` on an `ActionController::Metal` controller expanded from `action_methods`, which for a Metal subclass counts every public method of an included module, so `render`, `head`, `_prefixes`, `view_paths` and the rest were exported with operationIds such as `pings_render`. The catch-all now lists only the controller's own actions, as it does on an `ActionController::Base` controller. (#161)
|
|
37
|
+
- **An array `example:` written with date parts was exported as `[{}]`.** `array :items, example: [{ "on(1i)" => "2026", "on(2i)" => "10", "on(3i)" => "17" }]` was published as `[[{}]]`, though a request with the same parts reads as `2026-10-17`, and the server would fill in the sub-field's default for the published payload. The example is now judged on the hash with the date parts assembled, and is exported as `[{"on" => "2026-10-17"}]`. This covers `:date` and `:datetime` parts, in arrays and in a nested block inside an array. Requests are unchanged. (#162)
|
|
38
|
+
- **A `:json` `default:` or `example:` holding NaN or Infinity loaded, and the export raised.** `optional :meta, :json, default: { "score" => Float::NAN }` loaded, and `JSON.generate(json_schema)` then raised `JSON::GeneratorError`. No JSON request can carry NaN, so the value is one no client can send. A non-finite Float at any depth of a `:json` default or example, including a `:json` sub-field and a `:json` value inside an array, now raises `ArgumentError` at class load, naming the field and the path, such as `x[1].y`. Requests are unchanged. (#163)
|
|
39
|
+
- **A midnight-UTC `Time` sent to a `:date` before 1582 was read as the wrong day.** `Time.utc(1500, 1, 1)` became 1499-12-23, because `Time#to_date` labels days before the reform in the Julian calendar, while the String `"1500-01-01"` is proleptic Gregorian. The same applied to a `DateTime` and a `TimeWithZone`, and to `in:` members and `default:` values, which never matched or were exported as a different day. The day is now built from the instant's own year, month and day, so a Time, a DateTime and the String `"1500-01-01T00:00:00Z"` all give 1500-01-01. (#164)
|
|
40
|
+
- **A pre-1582 `DateTime` was read wrongly without ActiveSupport.** With a plain `require "permittable"`, `DateTime.new(1500, 1, 1)` sent to a `:date` landed on 1500-01-10. It now reads the DateTime's own calendar labels and gives 1500-01-01, with or without ActiveSupport. The days 1582-10-05 to 1582-10-14 do not exist in the calendar a `:date` reads, so a Time or DateTime on one of them is `invalid_type`, and an `in:` member on one raises the normal class-load error naming the field. A `:datetime` still accepts those days. (#164)
|
|
41
|
+
|
|
42
|
+
### Documentation
|
|
43
|
+
- **The README now says that a catch-all `rescue_from` declared after `include Permittable` wins.** `include Permittable` registers `rescue_from Permittable::InvalidParameters`, and Rails searches handlers from the last declared, so a `rescue_from StandardError` placed after the include answers an invalid request with its own response, often a 500, and the gem's 422 never runs. The README gives the two fixes: declare the catch-all before the include, or declare `rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters` after it. (#158)
|
|
44
|
+
|
|
45
|
+
### Development
|
|
46
|
+
- **Bump `sqlite3` 2.10.0 to 2.10.1 and `rubocop` (bundler-minor-patch group).** Development dependencies only. (#167)
|
|
47
|
+
|
|
3
48
|
## 0.13.0 (2026-10-24)
|
|
4
49
|
<!-- title: the audit and the OpenAPI export follow mounted engines and Metal controllers, and a path variable is documented as one -->
|
|
5
50
|
|
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,
|
|
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
|
|
|
@@ -716,7 +734,7 @@ What you give up is the shape. What you keep:
|
|
|
716
734
|
| `max_depth:` | Caps **container nesting**, counting arrays as a level: `{"a": 1}` is 1, `{"a": {"b": 1}}` and `{"a": [1, 2]}` are 2, `{"a": [{"b": 1}]}` is 3. Violation code `depth` |
|
|
717
735
|
| `validate:` / `transform:` | See the whole hash, so any check you can write in Ruby still applies |
|
|
718
736
|
| `model:` | The field maps onto a column like a scalar does, so the [drift guard](#the-schema-drift-guard) still catches a dropped `metadata` column |
|
|
719
|
-
| `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash, and are checked against the field's own bounds at class load) |
|
|
737
|
+
| `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash holding no NaN or Infinity, and are checked against the field's own bounds at class load) |
|
|
720
738
|
|
|
721
739
|
Bounding it matters more than it looks: an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects. `max_depth:` and `length:` are how a contract says "opaque, but not unlimited" — which is strictly more than `permit(metadata: {})` can say.
|
|
722
740
|
|
|
@@ -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
|
|
|
@@ -773,6 +791,12 @@ Exported [OpenAPI](#exporting-openapi-docs-that-cannot-drift) tells the truth ab
|
|
|
773
791
|
|
|
774
792
|
Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope shown at the [top of this README](#permittable).
|
|
775
793
|
|
|
794
|
+
Rails searches `rescue_from` handlers from the last declared to the first, so a catch-all such as `rescue_from StandardError` declared *after* `include Permittable` answers an invalid request first, and the client gets that handler's response instead of the 422 or 400. Either declare the catch-all before the include, or re-declare the gem's handler after it:
|
|
795
|
+
|
|
796
|
+
```ruby
|
|
797
|
+
rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters
|
|
798
|
+
```
|
|
799
|
+
|
|
776
800
|
| Code | Raised when |
|
|
777
801
|
|---|---|
|
|
778
802
|
| `missing` | A required field is absent, or the `root:` key is absent (that one is a **400**) |
|
|
@@ -904,7 +928,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
|
|
|
904
928
|
|
|
905
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.
|
|
906
930
|
|
|
907
|
-
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.
|
|
908
932
|
|
|
909
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.
|
|
910
934
|
|
|
@@ -1015,6 +1039,8 @@ If this parameter is not backed by a column, declare it with virtual: true.
|
|
|
1015
1039
|
|
|
1016
1040
|
The error carries a ready-to-paste migration command, typed from your own field declaration.
|
|
1017
1041
|
|
|
1042
|
+
A field naming a column the model lists in `ignored_columns` raises too, but says so (remove it from `ignored_columns`, or declare it `virtual: true`) rather than suggesting a migration.
|
|
1043
|
+
|
|
1018
1044
|
#### Checking types too (opt-in)
|
|
1019
1045
|
|
|
1020
1046
|
A dropped column fails the deploy; a **retyped** one doesn't, unless you ask:
|
|
@@ -1402,7 +1428,7 @@ Both work on a controller class, a controller instance, or a [standalone `Contra
|
|
|
1402
1428
|
|
|
1403
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.
|
|
1404
1430
|
|
|
1405
|
-
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.
|
|
1406
1432
|
|
|
1407
1433
|
### Standalone contracts (no controller)
|
|
1408
1434
|
|
|
@@ -1493,7 +1519,7 @@ Where they legitimately differ, the spec names the reason and asserts the **dire
|
|
|
1493
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.
|
|
1494
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.
|
|
1495
1521
|
|
|
1496
|
-
|
|
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:
|
|
1497
1523
|
|
|
1498
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.
|
|
1499
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.
|
|
@@ -1502,7 +1528,9 @@ Six cases go the other way, and are worth knowing before you hand the document t
|
|
|
1502
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.
|
|
1503
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`.
|
|
1504
1530
|
|
|
1505
|
-
A `
|
|
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.
|
|
1506
1534
|
|
|
1507
1535
|
|
|
1508
1536
|
<details>
|
|
@@ -1515,6 +1543,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1515
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) |
|
|
1516
1544
|
| `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
|
|
1517
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` |
|
|
1518
1547
|
| `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
|
|
1519
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` |
|
|
1520
1549
|
| `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
|
|
@@ -1592,7 +1621,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
1592
1621
|
- `length:` that isn't a non-negative `Integer` or a `Range`, or a `Range` with a negative or fractional end (`..-1`, `-2..3`, `1.5..3.5`; a whole-number Float such as `1..10.0` is fine; leave an end off, or use `Float::INFINITY`, for no bound); an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
|
|
1593
1622
|
- An `in:` member the field's own type can't cast (`in: %w[1 two]` on an `:integer`, `nil` on a field that isn't `nullable:`, or a `Time` on a `:date` field that isn't exactly midnight UTC), or an `in:` `Range` whose endpoints a value of the field's type can't be compared with (`in: "1".."5"` on an `:integer`) — either would reject every request as `inclusion`
|
|
1594
1623
|
- 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`)
|
|
1595
|
-
- A `Time` `in:` member on a `:datetime` field finer than a nanosecond (`Time.at(1.1)`, a third of a second): a request's fraction is a finite decimal and the export prints nine digits, so no request could equal
|
|
1624
|
+
- A `Time` `in:` member, `default:` or `example:` on a `:datetime` field finer than a nanosecond (`Time.at(1.1)`, a third of a second): a request's fraction is a finite decimal and the export prints nine digits, so no request could equal the member, and a published default or example would be a different instant
|
|
1596
1625
|
- `validate:` or `transform:` that isn't callable
|
|
1597
1626
|
- 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
|
|
1598
1627
|
- A `default: nil` or `example: nil` on a field that isn't `nullable:`
|
|
@@ -103,6 +103,16 @@ module Permittable
|
|
|
103
103
|
found
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# An authored :datetime Time must survive the export's nine digits, at
|
|
107
|
+
# any depth, as a scalar `default:` or `example:` must (see Coercion).
|
|
108
|
+
def permittable_check_scalar(field, value)
|
|
109
|
+
Coercion.refuse_finer_than_nanosecond(field[:type], super)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def permittable_cast_element(field, element)
|
|
113
|
+
Coercion.refuse_finer_than_nanosecond(field[:of], super)
|
|
114
|
+
end
|
|
115
|
+
|
|
106
116
|
# Nor does it run an array-level validate:, which expects the transformed
|
|
107
117
|
# sub-fields a request hands it. The validating read has already run it
|
|
108
118
|
# on exactly those, so the stored read, which would hand it the
|
|
@@ -114,11 +124,13 @@ module Permittable
|
|
|
114
124
|
# What a client sends has no defaults filled in: an omitted sub-field
|
|
115
125
|
# stays omitted, at any depth, so the published example does not show a
|
|
116
126
|
# value (`cents: 0`) that no client sends. The walker fills them in; drop
|
|
117
|
-
# exactly what it filled, by the walker's own absence rule
|
|
127
|
+
# exactly what it filled, by the walker's own absence rule, judged on the
|
|
128
|
+
# hash the walker walked: one with the date parts read into their field.
|
|
118
129
|
def permittable_check_hash(fields, hash, **)
|
|
119
130
|
result = super
|
|
120
131
|
return result unless @as_sent
|
|
121
132
|
|
|
133
|
+
hash = permittable_read_date_parts(fields, hash)
|
|
122
134
|
fields.each do |field|
|
|
123
135
|
key = field[:name].to_s
|
|
124
136
|
next unless field.key?(:default)
|
|
@@ -130,6 +142,30 @@ module Permittable
|
|
|
130
142
|
result
|
|
131
143
|
end
|
|
132
144
|
|
|
145
|
+
# An authored hash is exported as a schema `default`/`example`, and JSON
|
|
146
|
+
# has no NaN or Infinity: JSON.generate raises on them when the document
|
|
147
|
+
# is written, long after the class loaded. A request cannot carry one
|
|
148
|
+
# (JSON has none; a form sends Strings), so only the authored value is
|
|
149
|
+
# held to it, here, and not permittable_check_json for every request.
|
|
150
|
+
def permittable_check_json(field, value)
|
|
151
|
+
status, read = super
|
|
152
|
+
return [status, read] unless status == :ok
|
|
153
|
+
|
|
154
|
+
at = non_finite_path(value, "")
|
|
155
|
+
return [:error, "holds NaN or Infinity at #{at}, which JSON cannot carry"] if at
|
|
156
|
+
|
|
157
|
+
[status, read]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Where the first NaN or infinite Float sits, as `a.b[1].c`, or nil.
|
|
161
|
+
def non_finite_path(value, path)
|
|
162
|
+
case value
|
|
163
|
+
when Float then path unless value.finite?
|
|
164
|
+
when Hash then value.each_pair.lazy.filter_map { |k, v| non_finite_path(v, path.empty? ? k.to_s : "#{path}.#{k}") }.first
|
|
165
|
+
when Array then value.each_with_index.lazy.filter_map { |v, i| non_finite_path(v, "#{path}[#{i}]") }.first
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
133
169
|
def permittable_violation(_field, param, code)
|
|
134
170
|
{ param: param, code: code.to_s }
|
|
135
171
|
end
|
|
@@ -49,6 +49,8 @@ module Permittable
|
|
|
49
49
|
|
|
50
50
|
fields.flatten.compact.each do |field|
|
|
51
51
|
unless klass.column_names.include?(field.to_s)
|
|
52
|
+
raise ArgumentError, ignored_column_message(label, klass, field) if ignored_column?(klass, field)
|
|
53
|
+
|
|
52
54
|
raise ArgumentError,
|
|
53
55
|
"#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
|
|
54
56
|
"#{column_migration_hint(klass, field, types)}"
|
|
@@ -204,6 +206,37 @@ module Permittable
|
|
|
204
206
|
klass.name || "Model"
|
|
205
207
|
end
|
|
206
208
|
|
|
209
|
+
# `klass.column_names` leaves out `self.ignored_columns`, so an ignored
|
|
210
|
+
# column that IS in the database reads as missing. Raising stays right
|
|
211
|
+
# (`Model.new(field: ...)` raises UnknownAttributeError), but the
|
|
212
|
+
# migration the missing-column message teaches would duplicate it.
|
|
213
|
+
def ignored_column?(klass, field)
|
|
214
|
+
klass.respond_to?(:ignored_columns) && klass.ignored_columns.map(&:to_s).include?(field.to_s)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The column may have been dropped since it was ignored, with its name
|
|
218
|
+
# left in ignored_columns; the table itself says which. That case needs
|
|
219
|
+
# different advice: removing the name would only lead to a migration
|
|
220
|
+
# that re-adds a column the app no longer wants.
|
|
221
|
+
def ignored_column_message(label, klass, field)
|
|
222
|
+
subject = "#{label}: '#{field}' is in #{model_expr(klass)}'s ignored_columns"
|
|
223
|
+
if column_in_table?(klass, field)
|
|
224
|
+
"#{subject} (table: #{klass.table_name}), so the model ignores that column " \
|
|
225
|
+
"and assigning it raises ActiveModel::UnknownAttributeError. " \
|
|
226
|
+
"Remove it from ignored_columns, or declare the field virtual: true if the app handles it itself."
|
|
227
|
+
else
|
|
228
|
+
"#{subject} but the table has no such column (table: #{klass.table_name}). " \
|
|
229
|
+
"Remove it from ignored_columns and the field from the contract, " \
|
|
230
|
+
"or declare the field virtual: true if the app handles it itself."
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Unlike `column_names`, the connection's own column list includes the
|
|
235
|
+
# columns the model ignores.
|
|
236
|
+
def column_in_table?(klass, field)
|
|
237
|
+
klass.connection_pool.with_connection { |connection| connection.column_exists?(klass.table_name, field) }
|
|
238
|
+
end
|
|
239
|
+
|
|
207
240
|
def column_migration_hint(klass, field, types)
|
|
208
241
|
type = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
209
242
|
column = [field, type].compact.join(":")
|
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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)
|