permittable 0.11.1 → 0.12.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: be564cf1f5aa7d54ad207d4a32ef3327a7a03346bd3945bdcdcc41eb6f96cca4
4
- data.tar.gz: 24c723ef714fc5c092ecd243c3391f8aecdc0399454c6af8cafeb0efff750e7c
3
+ metadata.gz: 737a1777a6da7e8bcce3e969b82796279f0b5f1f51f3497abad696c4622f6951
4
+ data.tar.gz: 8c01787cc7081db7bbf6f4f0ce5a1f337b32e4d801d96bd6bd1f82678092c42e
5
5
  SHA512:
6
- metadata.gz: da85ce6eb68d7b4a2225dd35b8b43d5e8e3f2bb5ccd9ea03a877b25bba8f7f18f51ecc3e720b0f8a3d68f6e882ef47e955ac293ad513c61ec232a5a1b82c505a
7
- data.tar.gz: 8401cd45a001ddec34c642af47f2aa6cab961beba5632e952f5868e56f0add46635973934a83c7f385f05682fa5f9f2ad5ee1d5a14800c2f5085bbd6c894d14a
6
+ metadata.gz: 19320e84873a06b21eec376ddb236275ac7beaf217d29eed91e3dfab1e26c644c35538c6549ef57a73c6dd95b8bc9c5305aef8ed40075c3823f5d8a53dfd5404
7
+ data.tar.gz: 99cf9b0eee28ab54f31388fe647280b4f372022dc1bfc02ff01a09ef70032d68e0fde76816a51699fc9127b85f5230cd6938c2f4899981f954ec58d3e87a8de2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.12.0 (2026-10-21)
4
+ <!-- title: stricter numbers, dates and class-load checks, and matchers that stop passing for the wrong reason -->
5
+
6
+ Twenty-one changes to how a contract reads input, how it is checked and how its errors are rendered, plus one documentation fix and two small additions: the `DECIMAL_DIGIT_ALLOWANCE` constant (#110) and `date_select` multiparameter support (#128). A number a cast cannot hold, a decimal with an enormous exponent, a `DateTime` with a time of day, a `:datetime` that no request can match, and a `length:` Range with a negative end are now `invalid_type` or a class-load error where they were stored as something else or crashed later. A blank element a form's hidden field sends is dropped from a scalar array. `use` refuses a Contract whose `finalize` it would drop, and `use G, optional: true` no longer fills in the group's defaults on a partial update. A Hash, Array or Set subclass that keeps its base `include?` is read as a list. And the RSpec matchers stop passing in the wrong direction: a negated `accept_params` with no rule, a qualifier on the wrong matcher, an index on a field that is not an array, a Contract run around `#call`, and a CSRF parameter the controller exempts. An app whose `render_error` is private, as Rails convention makes helpers, now renders its own error body.
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 a client receives. Before upgrading, read the entries that match what you use. A request can now be refused: a number that underflows to zero or that a cast cannot convert (a `Complex`), a `:datetime` offset such as `+24:00` or `XYZ` (#100), a `date_select` field with a missing or non-integer `(ni)` part where an optional field used to drop the parts silently (#128), a `:decimal` string whose plain form would dwarf the request, a `Time` or `DateTime` with a time of day sent to a `:date`. A boot can now fail: a `:datetime` `in:` member finer than a nanosecond, a `length:` Range with a negative, fractional or non-numeric end, a `use` of a Contract with a `finalize`, a scalar declared over a PostgreSQL array column under `check_column_types`, a `DateTime` `default:` at noon, and an empty `config_for` `in:` (#126). An action can now receive something different: a `:json` field's `default:` as a frozen string-keyed copy (a symbol-keyed default now gives String `keys` and `to_h`), no defaulted fields from `use G, optional: true`, an app's own error body from a private `render_error` (#150), `["a"]` for a JSON `["", "a"]`, a `:date` where a midnight `Time` was refused. And a spec can fail where it passed: the negated and qualified forms of the behaviour matchers, and a matcher run on a Contract.
9
+
10
+ ### Changed
11
+ - **`use G, optional: true` drops `default:` from the fields it relaxes.** The README's PATCH recipe overwrote data: `use UserFields, optional: true` with `optional :plan, :string, default: "free"` handed the action `plan: "free"` for a request that only renamed the user, and `User#update` then downgraded every "pro" user. A default is filled in for every request that omits the field, which on an update is every column the client left alone. The relaxation now drops `default:` as it already dropped `required:`, at the top level only: a nested hash the client does send keeps its sub-fields' defaults, and the group's own create rule keeps all of them. The exported schema of the update rule follows. This also applies to `virtual: true` fields and to non-PATCH uses, such as a search rule that relied on `page: 1`; use `use G, optional: true, except: [:page]` and redeclare the field. (#121)
12
+ - **`use` refuses a Contract that declares a `finalize`.** `use SomeContract` splices only the contract's fields, so a cross-field rule such as "ends_on is not before starts_on" silently never reached the controller. `group_fields!` now raises `ArgumentError` at class load when `use` is given a Contract with a `finalize`, at the top level or in a nested block. The message says how to share both halves: declare the fields in a `Permittable.fields` group that the contract and the `permit_params` block each use, and declare `finalize` in each (a single proc passed as `finalize(&rule)` keeps the rule in one place). `only:` and `except:` do not lift the refusal, since a `finalize` is opaque. A Contract without `finalize` is still accepted. (#122)
13
+ - **A Hash, Array or Set subclass that keeps its base `include?` is read as a list.** `in:` was read as a list only when its class was exactly `Array`, `Set`, `Hash` or `HashWithIndifferentAccess`, so `in: Rails.application.config_for(:plans)`, an `ActiveSupport::OrderedOptions` with Symbol keys, asked `include?("pro")` about `:pro` and refused every request, with no enum in the export. What makes a list is now which module owns the object's `include?`: a subclass that keeps its base's is read as that list at class load (an Array subclass through `.to_a`, a Hash or Set subclass stored as a Set) and exported as an enum. `HashWithIndifferentAccess` keeps its exemption, and a gem that prepends a module into `Array`, `Hash` or `Set` still does not turn plain lists opaque. A subclass override, a singleton method or an `extend` is used as given, which means a plain Hash or Array with a singleton or `extend`ed `include?` override is now used as given where it used to be read as a list. An empty `config_for` `in:` boots on 0.11.1 (#125) but raises on 0.12.0, because the object is now read as a list at class load and an empty list is refused as `in: []` always was. (#126)
14
+ - **A `:date` reads a `Time` or `DateTime` only at midnight UTC.** `cast_date` matched `when Date`, and a `DateTime` is a `Date`, so a noon `DateTime` was handed back whole, time of day included, and exported as `"2026-01-05T12:00:00Z"`. A `Time`, `DateTime` or `TimeWithZone` that is exactly midnight UTC is now that UTC date, returned as a plain `Date`, and any other instant is `invalid_type`. A `Time` or `TimeWithZone` at midnight UTC is accepted where it was refused, a `DateTime` or `Time` with a time of day, or at midnight in another zone, is refused where a `DateTime` was returned whole, and a noon `DateTime` `default:` fails at class load like any default that violates its field. (#127)
15
+ - **`date_select`, `datetime_select` and `time_select` submitted values a `:date` or `:datetime` field ignored or rejected.** Rails sends one value as `starts_on(1i)`, `starts_on(2i)` and `starts_on(3i)`; an optional field silently dropped them, a required one answered `missing`, and `unknown: :error` reported every part as unknown. A declared `:date` or `:datetime` field now assembles its parts into the ISO 8601 string it already accepts, which then takes the normal path (cast, `in:`, `validate:`, `transform:`, nullable and absence handling). Blank parts read the field as sent empty, a missing or non-integer part is `invalid_type`, and a `:datetime` reads a blank or missing hour, minute or second as `00`, which is what `f.date_select` on a datetime column sends. When both the field's key and its parts are sent, the key wins and the parts go through the unknown-key policy. Datetime parts are read as UTC. `time_select(ignore_date: true)` remains unsupported: it sends no date, so the field is `invalid_type`. (#128)
16
+ - **A `:datetime` `in:` member finer than a nanosecond fails at class load.** A member authored as a `Time` is stored exact, but the exporter prints at most nine fractional digits and a request's fraction is a finite decimal, so `in: [Time.at(1.1).utc]` could never be matched: the schema listed a value the server always refused. `Coercion.cast_in_member` now raises "finer than a nanosecond, so it never equals a request; round it, e.g. with .round(9)". A member read off the clock (`Time.now`) and a nanosecond-exact member still load and match. (#106)
17
+ - **`length:` Ranges with a negative, fractional or non-numeric end fail at class load, and an infinite end is exported as no bound.** `length: ..-1` made every value a `length` violation forever, `length: -2..3` exported `minLength: -2`, `length: 1.5..3.5` exported fractional bounds that Ajv rejects for the whole document, and `length: 1..Float::INFINITY` made `JSON.generate` raise on the exported schema. Each end of a Range must now be a non-negative whole number (an Integer, or a whole Float, Rational or BigDecimal such as `10.0`) or open (`nil`, or an infinite Float on its own side), or the contract raises naming the fix (`1..` or `..80`). The exporter reads an infinite end as no bound. (#99)
18
+ - **A `:datetime` string is read as Gregorian, and a zone Ruby cannot honour is refused.** `DateTime.parse` defaults to the 1582 Italian reform, so with only `require "permittable"` (a standalone Contract outside Rails) `"1500-01-01"` became `1500-01-10`, while a Rails app kept the day. Strings are now parsed with the proleptic Gregorian calendar, so both agree. An offset Ruby cannot honour (`+24:00`, `+25:00`, `-99:00`, `+99`, an unknown abbreviation such as `XYZ`) used to be dropped and the wall clock read as UTC; a string whose parse reports a zone must now carry an offset under a day, or it is `invalid_type`. Every real offset (`+14:00`, `+23:59`, `-00:00`) and every abbreviation Ruby knows is read as before. (#100)
19
+ - **A `:decimal` that underflowed to zero is `invalid_type`.** `C.cast(:decimal, "1e-99999999999999999999")` returned `0.0`, storing a nonzero client value as zero, which `:float` already refused. `:decimal` now applies the same rule: a zero result from a string naming a nonzero significand is `invalid_type`. Genuine zeros and small representable values such as `"1e-400"` are unaffected. (#101)
20
+ - **A `:decimal` string whose plain form would dwarf the request is `invalid_type`.** `"1e4000000000000000000"` passed the cast as a finite value and then raised `NoMemoryError` the moment a database adapter, JSON rendering or a log line formatted it, and `"1e1000000000"` tried to allocate a gigabyte from a dozen bytes. A String whose plain form would run past both a thousand digits and the string's own length is now refused: `exponent.abs > [1000, source.length].max`. `"1e400"`, `"1e999"` and `"-1e-999"` still cast; a number spelled out digit by digit is accepted however long it is; `"1e1000"` and larger exponents are refused where they cast, and on the negative side `"1e-1001"` casts and `"1e-1002"` is refused (BigDecimal's exponent is the digit count plus one). The limit is the constant `DECIMAL_DIGIT_ALLOWANCE`. (#110)
21
+ - **A `Numeric` that a cast cannot hold is `invalid_type`, never `0.0` or a raise.** A `BigDecimal("1e-400")` or `Rational(1, 10**400)` given to `:float` was stored as `0.0`, and `Complex(1, 1)` raised `RangeError` out of `Contract#call` (a 500 behind `call!`), as did a custom Numeric whose `to_f` or `to_s` raised for `:float`, `:decimal` and `:string`. The Numeric branch now applies the same underflow rule as a String, any conversion failure of the caller's value in `:float`, `:decimal` and `:string` is `invalid_type`, and a `Complex` is never a `:float`, matching `:integer` and `:decimal`. Zero in any spelling and every value a Float can hold, subnormals included, are unchanged. This reaches a standalone `Contract` and authored `default:`/`example:` values. (#145)
22
+ - **A scalar array drops the blank element a form's hidden field sends.** `collection_check_boxes` and `select multiple: true` send a hidden `""` so that unchecking every box still sends the key, and `array :tag_ids, of: :integer` rejected every such submission as `invalid_type`; an `of: :string` array kept a blank tag. A scalar array's `""` elements are now treated as absent, dropped before casting, before `length:` counts, and before `validate:` and `transform:` see the array. Violation paths keep the index the client sent each element at, a `null` element is still `invalid_type`, and arrays of hashes are untouched. A JSON client sending `["", "a"]` to an `of: :string` array now gets `["a"]`. (#111)
23
+ - **`permittable:generate` drafts a PostgreSQL array column as an array, and `check_column_types` refuses a scalar declared over one.** Rails reports `t.string :tags, array: true` as `type: :string` with `array?: true`, so the generator drafted `optional :tags, :string`, which rejects the Array the column stores, and the guard compared groups and let the wrong shape load. The draft is now `array :tags, of: :string`, with `required: true` when a NOT NULL column without a default makes a create require it, and the same `length:` TODO a scanned `tags: []` gets; an array of `json`, `jsonb` or `hstore` elements, which `of:` cannot express, becomes a TODO. With `check_column_types` on, a scalar or `:json` declaration over an array column raises at class load, spelling the `array :tags, of: :string` declaration to write or `virtual: true`. (#97)
24
+ - **`permittable:generate` leaves a TODO for a json column whose default is an array.** `:json` accepts only an object, so `t.jsonb :labels, null: false, default: []` drafted `optional :labels, :json`, which then rejects exactly what every client sends. The column cannot say whether its elements are scalars or objects, so the draft is now a TODO naming both declarations, `array :labels, of: :string` or `array :labels do ... end`. A json column with an object default, or none, still drafts as `:json`. (#105)
25
+ - **Negated `accept_params` and `reject_params` fail when no rule covers the action, and refuse qualifiers.** The matcher defined no `does_not_match?`, so RSpec negated `matches?`, and `not_to accept_params(valid).for_action(:craete)` passed on a typo, as did `not_to accept_params(...)` on a controller with no contracts. `does_not_match?` now resolves the rule as `matches?` does, fails with the existing problem text when none resolves, and otherwise passes only on the opposite outcome. A negated `returning` or `with_violation` raises `ArgumentError` naming the positive form, and a reused matcher no longer prints a previous run's "has no contract covering" message. A negated assertion that passed on a typo will now fail. (#94)
26
+ - **`returning` on `reject_params` and `with_violation` on `accept_params` raise.** Both were documented as one matcher's qualifier and silently ignored on the other, so `accept_params(...).with_violation("name", :missing)` asserted only the bare outcome and described an assertion it never made. Each now raises `ArgumentError` at chain time, naming the matcher the qualifier belongs to. (#95)
27
+ - **`permit_param` holds `[n]` indexes to the field they index.** `permit_param("items[0].sku")` passed for a nested hash where the runtime, given `items: [{ sku: "A" }]`, answers `invalid_type`, and `not_to permit_param("items[0].sku")` failed. Each path segment now takes at most one `[n]` and only on an array field; a path that breaks this fails naming the field's shape (`:items is a nested hash, not an array`), and its negated form passes. Below a `:json` field the path stays opaque, indexes included. (#117)
28
+ - **`accept_params` and `reject_params` on a `Contract` run through `Contract#call`.** The matcher built a throwaway host and set its params directly, so `Contract#normalize_input` never ran: `accept_params([{ "id" => "1" }])` passed where `feed.call` raises `ArgumentError`, and `reject_params("garbage").with_violation("id", :missing)` passed too. A Contract subject now runs through `#call` itself, so the `ArgumentError` propagates, the `unknown:` override is the Contract's own, and a rejection is labelled with the controller `#call` reports, `"Permittable::Contract"`, rather than `nil`. (#116)
29
+ - **The matchers exempt the controller's configured CSRF parameter.** The runtime exempts the app's `request_forgery_protection_token` from `unknown: :error`, but the matcher's bare host had no such method and exempted nothing, so `reject_params(name: "x", csrf_token: "abc").with_violation("csrf_token", :unknown)` passed for a controller that serves that POST with a 200, and `not_to accept_params` of it passed too. For a subject that answers `request_forgery_protection_token`, the host now exempts that class-level name; a standalone Contract still exempts nothing. Route path parameters cannot be mirrored, so a payload `id` on a member action is reported unknown, and a per-instance token override is not seen; the README says both. (#146)
30
+ - **A `:json` field's `default:` now reaches the action as a frozen indifferent-access copy, with string keys, not as the Hash as authored.** A symbol-keyed authored default (`default: { theme: "dark" }`) now has String keys: `keys` and `to_h` give Strings, `== { theme: "dark" }` is false, and `**default` passes String keys, while `default[:theme]` and pattern matching on `{theme:}` still work. The same copy is what the export and `with_default` see. The change fixes a `validate:` that reads `p[:theme]`: a request's hash reaches `validate:` as an indifferent-access deep copy, but the authored `default:` and `example:` were checked as the raw Hash, so `default: { "theme" => "dark" }` raised at class load ("violates its own contract") although every request passed, and a `validate:` that edited its argument edited the host's own default. The value is now read the way a request's hash is read, through the same bounds checks and deep copy, and the published `example:` is a fresh frozen indifferent-access copy that `validate:` cannot reach. `with_default` reads a `:json` expectation the same way, so either key spelling matches. (#123)
31
+ - **A private `render_error` is delegated to.** `render_envelope` checked `respond_to?(:render_error)`, which is false for a private method, so an app that follows the Rails convention of private helpers on `ApplicationController` got the gem's inline `{success:, error:}` envelope instead of its own body, with no warning. A private `render_error` is now delegated to when it takes `message:`, `code:` and `status:` (by name or through `**kwargs`) and requires nothing else: no required positional argument and no required keyword outside `message`, `code`, `status` and `errors`. A private helper with another signature, such as `render_error(message, status)` or `render_error(message:, status:)`, is still left alone. So is a private one that cannot be inspected (only `respond_to?(:render_error, true)` answers it). Apps with such a helper now get their own body instead of the gem's envelope. (#150)
32
+
33
+ ### Fixed
34
+ - **The README's JSON Schema table lost its `finalize` row in 0.11.1.** #133 added the row that says a rule's `finalize` block is exported as `x-permittable-finalize: true`, on the rule's object (inside the `root:` wrapper) and on the operation. Resolving the conflict with #142's `sensitive: true` row in the 0.11.1 merges dropped it. It is restored as written. Documentation only. (#152)
35
+
3
36
  ## 0.11.1 (2026-10-18)
4
37
  <!-- title: sensitive values stay out of logs and exports, a crash and a wrong label fixed, and generated documents that match what the server does -->
5
38
 
data/README.md CHANGED
@@ -608,7 +608,7 @@ Which options are legal depends on the field kind — anything else raises at cl
608
608
 
609
609
  | Option | Scalar | Array | Nested | Meaning |
610
610
  |---|:---:|:---:|:---:|---|
611
- | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), or any other object answering `include?` (used as given, and read on every request, so it may be empty when the class loads: a live list filled in an initializer or `after_initialize` is fine, and for the same reason a `default:` or `example:` is not checked against it at class load, though every other rule still is) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
611
+ | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (an `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works — or a subclass of one that keeps its `include?`, such as the `ActiveSupport::OrderedOptions` that `config_for` returns), or any other object answering `include?` (used as given, and read on every request, so it may be empty when the class loads: a live list filled in an initializer or `after_initialize` is fine, and for the same reason a `default:` or `example:` is not checked against it at class load, though every other rule still is) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
612
612
  | `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
613
613
  | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
614
614
  | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
@@ -675,10 +675,10 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
675
675
  |---|---|---|
676
676
  | `:string` | `String`, returned in the encoding it arrived in; `Numeric`/`true`/`false` are stringified | Arrays, hashes, a `String` whose bytes are invalid in its own encoding (`"caf\xC3"`) |
677
677
  | `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
678
- | `:float` | `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "` |
679
- | `:decimal` | `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 `Rational` no decimal holds exactly (`Rational(1, 3)`) |
678
+ | `: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
+ | `: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
680
  | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
681
- | `:date` | `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, and **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`) |
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
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"`) |
683
683
  | `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
684
684
 
@@ -686,14 +686,14 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
686
686
 
687
687
  **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
688
 
689
- **Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal` has no exponent limit, so `"1e400"` is fine there — but `BigDecimal("NaN")` and `BigDecimal("Infinity")` *succeed* where `Float()` raises, so those literal strings are rejected explicitly.
689
+ **Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal`'s exponent range is far wider, so `"1e400"` is fine there, though not unlimited: a string past it (`"1e-99999999999999999999"`) underflows to zero or overflows to Infinity and is `invalid_type` by the same rule. A string whose plain form would run past a thousand digits and past its own length (`"1e4000000000000000000"`, 22 bytes) is `invalid_type` too, because formatting it to save, render or log it would exhaust memory; a number spelled out digit by digit is accepted however long it is. And `BigDecimal("NaN")` and `BigDecimal("Infinity")` *succeed* where `Float()` raises, so those literal strings are rejected explicitly.
690
690
 
691
691
  **Numbers are spelled the way a client spells them.** `Integer()`, `Float()` and `BigDecimal()` all read underscore digit separators and surrounding whitespace — conveniences for a number literal in Ruby source — so `"1_8"` would be eighteen and `" 99 "` ninety-nine. A numeric string must instead be canonical: an optional sign, digits, an optional fraction and an optional exponent (`"-12"`, `"007"`, `".5"`, `"1.5e10"`). Anything else is `invalid_type`.
692
692
 
693
693
  Two more behaviours worth committing to memory:
694
694
 
695
695
  - **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
696
- - **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
696
+ - **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted. A zone the parser cannot honour — an offset of a day or more (`+25:00`), or an abbreviation it does not know — is `invalid_type` rather than read as UTC, and dates are read in the proleptic Gregorian calendar ISO 8601 uses, so `"1500-01-01"` is that day whatever the host has loaded.
697
697
 
698
698
  ### Free-form hashes (`:json`)
699
699
 
@@ -726,7 +726,7 @@ In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{
726
726
 
727
727
  ### Absence, defaults, and partial updates
728
728
 
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 `""`.
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`.
730
730
 
731
731
  That single rule produces the behaviour you want from a `PATCH`:
732
732
 
@@ -790,7 +790,7 @@ Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
790
790
 
791
791
  **Status codes.** A bad root key renders **400** — the request is malformed; the envelope you asked for isn't there, or isn't an object. Field-level violations render **422** — well-formed, semantically wrong. The two root failures are told apart by their code: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}`), `invalid_type` when the client sent it with the wrong shape.
792
792
 
793
- **Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when the method can take it (it names `errors:`, takes `**kwargs`, or declares no keywords at all), so hosts documenting a three-keyword `(message:, code:, status:)` contract keep working. Otherwise the inline JSON shape is rendered. Either way, `render_invalid_parameters` is a normal method you can override, and [`Permittable.error_format = :problem`](#rfc-9457-problemjson) swaps the whole shape for RFC 9457 problem details. For full control beyond that, `error.details` gives you the structured violations to build from.
793
+ **Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when the method can take it (it names `errors:`, takes `**kwargs`, or declares no keywords at all), so hosts documenting a three-keyword `(message:, code:, status:)` contract keep working. A **private** `render_error`, the usual spelling on an `ApplicationController` so it never becomes a routable action, is delegated to as well when it takes `message:`, `code:` and `status:` and requires nothing else; a private one with some other signature (`render_error(message, status)`, or `render_error(message:, status:)`) is left alone. Otherwise the inline JSON shape is rendered. Either way, `render_invalid_parameters` is a normal method you can override, and [`Permittable.error_format = :problem`](#rfc-9457-problemjson) swaps the whole shape for RFC 9457 problem details. For full control beyond that, `error.details` gives you the structured violations to build from.
794
794
 
795
795
  ### Custom error messages (`message:`)
796
796
 
@@ -908,6 +908,10 @@ Rails merges its own keys into `params`: `controller`, `action`, and `format` fr
908
908
 
909
909
  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
910
 
911
+ **Rails' `date_select`, `datetime_select` and `time_select`** submit one value as several keys, each named for its position: `starts_on(1i)` is the year, `(2i)` the month, `(3i)` the day, `(4i)` the hour, `(5i)` the minute and `(6i)` the second. A `:date` or `:datetime` field reads these parts as its own value whenever its own key is absent. `permitted_params` carries `"starts_on" => Date`, never the parts, and `unknown:` does not report the parts it read. The parts are assembled into the ISO 8601 string the field already accepts (`"2026-10-17"`, or `"2026-10-17T09:30:00"` with seconds `00` unless `(6i)` is sent), and that string goes through the field's cast and rules like any other value. A `:date` or `:datetime` needs `(1i)`–`(3i)`; a `:datetime`'s `(4i)`, `(5i)` and `(6i)` are optional. As in ActiveRecord, the field reads as if it were sent empty (absent, or an explicit null when it is `nullable:`) when any one of `(1i)`–`(3i)` is blank, which is what a select left on its `prompt:` or `include_blank:` sends. It is any one, not all of them, because Rails sends a part it discards through a hidden input: `date_select(:event, :on, discard_day: true, prompt: true)` always sends `on(3i)=1`, so a month and year picker nobody touched arrives as `""`, `""`, `"1"` and is still no date. Once the date is there, a blank or missing `(4i)`, `(5i)` or `(6i)` reads as `00`, as ActiveModel reads it, so `date_select` on a `:datetime` field (which sends the date alone) and `datetime_select(discard_hour: true)` give midnight, as they do in ActiveRecord: a `time_select` left on its prompts for an empty value sends the hidden date `1`, `1`, `1`, and gives `0001-01-01T00:00:00`, as it does in ActiveRecord. A part is a String of digits or a non-negative Integer (a JSON body cannot send the String a form does). Any other part, a missing `(1i)`–`(3i)` that has no blank part beside it (a blank part among those sent reads the field as sent empty first, whichever of the others is missing, as it does in ActiveRecord) or a part that is not plain digits (`"Oct"`, `"-1"`, `"1.5"`, a Float, a nested hash or array), is `invalid_type` on the field itself (`event.starts_on`). `time_select`'s `ignore_date: true` is not supported: it sends only `(4i)` and `(5i)`, a `:datetime` needs a date, and the field is `invalid_type`. If a request sends both `starts_on` and its parts, `starts_on` wins the value and the parts are left alone: they are ordinary undeclared keys, so `unknown: :error` flags them (`event.starts_on(1i)`), `:log` logs them and `:ignore` drops them. They are not hidden from `unknown:`, because a legacy action that still calls `permit(:starts_on)` hands them to ActiveRecord, which assigns them after the plain key. Only a declared `:date` or `:datetime` field reads parts (a `:date` has no `(4i)`); for any other name, they are ordinary undeclared keys. When monitor mode passes a rejected request through raw, the parts come back as they were sent.
912
+
913
+ The parts carry no time zone, so a datetime read from them is UTC, like any zoneless string ([see above](#types-and-strict-coercion)). ActiveRecord reads the same parts in `Time.zone`. So in an app with `config.time_zone = "Tokyo"`, a form showing 09:30 gives the contract 09:30 UTC, where strong parameters would have stored 00:30 UTC. Such an app can read the wall-clock time in its own zone with `transform: ->(t) { Time.zone.local(t.year, t.month, t.day, t.hour, t.min, t.sec) }`. Use that only where clients send zoneless times: a string with an explicit offset has already been converted to UTC, and the transform would shift it again.
914
+
911
915
  ### Reusing fields (`Permittable.fields` and `use`)
912
916
 
913
917
  A growing API produces two kinds of duplication: the `address` block three controllers want, and the `update` contract that is the `create` contract with nothing mandatory. A **field group** is a reusable field list — the same frozen data a contract's fields are, without the contract around them.
@@ -934,18 +938,20 @@ class UsersController < ApplicationController
934
938
  use UserFields
935
939
  end
936
940
 
937
- # PATCH: the same fields, nothing mandatory.
941
+ # PATCH: the same fields, nothing mandatory, no defaults.
938
942
  permit_params :update, root: :user, model: User do
939
943
  use UserFields, optional: true
940
944
  end
941
945
  end
942
946
  ```
943
947
 
948
+ The defaults go because a default is filled in for every request that omits the field. On a `PATCH` that would overwrite the column the client left alone: with `plan`'s `default: "free"` kept, `PATCH {"user": {"name": "Jo"}}` would hand the action `{"name" => "Jo", "plan" => "free"}` and downgrade every `"pro"` user who renamed themself. The exported schema of the update rule leaves them out too.
949
+
944
950
  `use` splices the group in **at the point of use**, in the group's own order, exactly as if the fields had been typed there — so the request-time behaviour, the [drift guard](#the-schema-drift-guard), `sensitive:` registration and the [exported schema](#exporting-openapi-docs-that-cannot-drift) are all identical to the inline spelling. It works at the top level of a contract, inside a nested or array block, and inside another group.
945
951
 
946
952
  | Option | Meaning |
947
953
  |---|---|
948
- | `optional: true` | Relax every spliced field. **Top level only** — if a client sends an `address` at all, the address's own required sub-fields still hold. Types, bounds and `default:` are untouched, so `use UserFields, optional: true` is a complete `PATCH` contract |
954
+ | `optional: true` | Relax every spliced field: nothing is mandatory, and no `default:` is filled in. **Top level only** — if a client sends an `address` at all, the address's own required sub-fields and defaults still hold. Types and bounds are untouched, so `use UserFields, optional: true` is a complete `PATCH` contract |
949
955
  | `only:` / `except:` | Select a subset, in the group's own order. Mutually exclusive |
950
956
 
951
957
  Because a group is built by the same builder a contract is, **every declaration is validated when the group is defined** — a typo fails once, at the group, instead of at each contract that uses it. Two more things fail at class load rather than silently: `only:`/`except:` naming a field the group doesn't declare (so a typo can't quietly drop a field), and a field declared twice. That last one makes overriding deliberate:
@@ -957,7 +963,7 @@ permit_params :create do
957
963
  end
958
964
  ```
959
965
 
960
- A group is deliberately **not** a contract: it has no `root:`, `unknown:`, `model:` or `mode:` — those describe the request being validated, not a set of fields — and `finalize` is rejected for the same reason. A [standalone `Contract`](#standalone-contracts-no-controller) does answer `#fields`, though, so `use SomeContract` lets a webhook payload and a controller action share one definition instead of two that drift.
966
+ A group is deliberately **not** a contract: it has no `root:`, `unknown:`, `model:` or `mode:` — those describe the request being validated, not a set of fields — and `finalize` is rejected for the same reason. A [standalone `Contract`](#standalone-contracts-no-controller) does answer `#fields`, though, so `use SomeContract` lets a webhook payload and a controller action share one set of fields instead of two that drift. Only the fields: `use` cannot carry a contract's `finalize`, so using a contract that declares one raises at class load rather than silently dropping its cross-field rule. To share both, declare the fields in a `Permittable.fields` group that the contract and the controller each `use`, and declare `finalize` in each; passing one proc to both as `finalize(&EndsAfterStart)` keeps the rule itself in one place.
961
967
 
962
968
  ### Output reshaping (`transform:` and `finalize`)
963
969
 
@@ -1038,6 +1044,8 @@ When enabled it compares **groups**, not exact types, so it fires on a genuine c
1038
1044
 
1039
1045
  Any column type **not** in that table — `json`, `jsonb`, `binary`, an adapter's own `inet` or `money` — is never checked. A contract has no faithful type for those, so whatever you improvised is left alone rather than guessed about.
1040
1046
 
1047
+ **A PostgreSQL array column is checked for its shape.** Rails reports `t.string :tags, array: true` as a `string` column, so a scalar `optional :tags, :string` sits in the right group and would pass — yet it rejects as `invalid_type` every array the column stores. A scalar or `:json` declaration over an array column fails at class load and names the fix, `array :tags, of: :string`.
1048
+
1041
1049
  **A Rails `enum` is compared by what clients send, not what the column stores.** An enum is submitted by name — `status: "shipped"` — so `optional :status, :string, in: Order.statuses.keys` is the right contract for an integer-backed enum, and a text declaration on any attribute in the model's `defined_enums` is not held to the column's group. It **must** carry that `in:`, though: assignment raises `ArgumentError` for a value the enum does not map, so without one `status: "bogus"` would pass the contract and become a 500 in the action. A text declaration with no `in:`, or with an `in:` listing anything the enum would refuse, fails at class load:
1042
1050
 
1043
1051
  ```
@@ -1201,7 +1209,7 @@ A scanned draft keeps the root its permit call names (a rootless `params.permit(
1201
1209
  The generator's one rule is **draft, don't guess** — everything it cannot know for sure stays visible instead of silently decided:
1202
1210
 
1203
1211
  - Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
1204
- - A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no faithful representation (`binary`, geometry types) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
1212
+ - A permitted key that isn't a column becomes `virtual: true` with a TODO; a PostgreSQL array column drafts as `array :tags, of: :string` with a `length:` TODO (an array of `json`/`jsonb` elements, which `of:` cannot express, is a TODO, and so is a `json`/`jsonb` column whose default is an array, since `:json` accepts only an object); a column type with no faithful representation (`binary`, geometry types) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
1205
1213
  - Drafted from the columns alone, `created_at`/`updated_at` and a primary key the database generates are left out: Rails or the database assigns them. A generated key is an auto-increment column (as the adapter reports it, even as one part of a composite key) or one with a default or default function, such as a uuid. A permit call that **lists** one keeps it, typed from its real column (`optional :created_at, :datetime`) and always `optional`, even though the column is NOT NULL — it is a column like any other, so the schema-drift guard stays on for it. A primary key the client must send is no such column: a natural key (`sku`, from `create_table :items, primary_key: :sku, id: :string`) or a part of a composite key that is not auto-increment is drafted like any other, `required` on `:create` when it is NOT NULL with no default.
1206
1214
  - **Comments are not code.** A commented-out `params.require(:admin).permit(:superuser)` kept for reference is skipped, so it can't contribute a root or a field to the draft. The source is tokenised with `Ripper` for this, because `#` is only sometimes a comment — a permit call inside `#{'#{...}'}` interpolation is live code and is still read, and quoted keys like `permit("name")` still work.
1207
1215
  - **Parentheses are optional.** A call is read with or without them (`permit :name, :email`, `expect user: [...]`), to the end of its statement, and a root spelled as a String (`require("user")`) is read like a Symbol.
@@ -1359,7 +1367,7 @@ end
1359
1367
 
1360
1368
  Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`, cast by the field's type just as the contract's list is, so `within(%i[draft published])` repeats the declaration as written), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`).
1361
1369
 
1362
- The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`.
1370
+ The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`. An `[n]` must index what the runtime indexes: an array field, once, since an array is one level deep. So `"address[0].zip"` on a nested `address` block fails, and its negated form passes, because the runtime refuses an Array there.
1363
1371
 
1364
1372
  It reads the contract, not the runtime mode. In enforce mode an undeclared key never reaches `permitted_params` — it is dropped from it, or the request is rejected under `unknown: :error` — so "not declared" means "not permitted" for code that reads `permitted_params`. The raw `params` still carries every key, as it always does. A rule in [monitor mode](#monitor-mode-roll-out-without-rejecting) is different: `permitted_params` hands back the raw params, so an undeclared `:admin` comes through it until the rule is switched to enforce. The matcher does not fail for that, because a monitor-mode rule is a rollout stage and the spec describes the contract being rolled out; the enforce switch is what makes the assertion hold at runtime.
1365
1373
 
@@ -1378,7 +1386,7 @@ expect(described_class).to reject_params(user: { name: "Jo", email: "nope" })
1378
1386
  .for_action(:create).with_violation("user.email", :format)
1379
1387
  ```
1380
1388
 
1381
- `returning` asserts the **cast, defaulted, transformed** output — the part `permit_param` can't reach, since it only reads the declaration. `with_violation` is repeatable and its code is optional.
1389
+ `returning` asserts the **cast, defaulted, transformed** output — the part `permit_param` can't reach, since it only reads the declaration. `with_violation` is repeatable and its code is optional. Each belongs to one matcher: `returning` to `accept_params` and `with_violation` to `reject_params`, and chaining either onto the other raises `ArgumentError` rather than being silently ignored.
1382
1390
 
1383
1391
  Failure messages name what actually happened:
1384
1392
 
@@ -1390,6 +1398,10 @@ expected UsersController to reject those params with user.age (inclusion), but t
1390
1398
 
1391
1399
  Both work on a controller class, a controller instance, or a [standalone `Contract`](#standalone-contracts-no-controller), and both read the **contract** rather than the rollout mode — a [monitor-mode](#monitor-mode-roll-out-without-rejecting) rule still `reject_params`, because the question is what the contract says, not what the deploy currently does with it.
1392
1400
 
1401
+ 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.
1402
+
1403
+ 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`.
1404
+
1393
1405
  ### Standalone contracts (no controller)
1394
1406
 
1395
1407
  The same DSL, callable on any Hash — webhook payloads, job arguments, service-object inputs, CSV rows:
@@ -1507,6 +1519,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1507
1519
  | nested block / `array` | `object` + `properties` / `array` + `items` |
1508
1520
  | `unknown: :error` | `additionalProperties: false`, at every nesting level |
1509
1521
  | `root:` | the wrapping object, itself required |
1522
+ | `finalize` | `x-permittable-finalize: true` on the rule's object (inside the `root:` wrapper) and on the operation; the cross-field checks themselves are opaque |
1510
1523
  | `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published, and so are those of an array that holds it at any depth, since they would carry its value, and so are its `in:` (list or Range) and a hand-written `format:` regexp, which would publish the secret itself. A field that declares either is flagged `x-permittable-custom-validation`, so the schema is looser than the server there; a `format:` preset such as `:email` is public and is still published |
1511
1524
 
1512
1525
  </details>
@@ -1572,9 +1585,10 @@ A bad contract is a programmer error, so it fails when the class loads — never
1572
1585
  - An unknown `normalize:` or `format:` preset, listing the presets
1573
1586
  - A `format:` that is neither a `Regexp` nor a preset name
1574
1587
  - `format:`, `length:`, or `normalize:` on a non-`:string` field
1575
- - `length:` that isn't a non-negative `Integer` or a `Range`; an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
1588
+ - `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?`
1576
1589
  - 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`
1577
1590
  - 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`)
1591
+ - 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 it
1578
1592
  - `validate:` or `transform:` that isn't callable
1579
1593
  - 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
1580
1594
  - A `default: nil` or `example: nil` on a field that isn't `nullable:`
@@ -1,7 +1,8 @@
1
1
  module Permittable
2
- # The request walker, pointed at an authored array `default:`/`example:`
3
- # at class load — the same permittable_check_array a request goes
4
- # through, so the two cannot drift apart. Two seams are overridden:
2
+ # The request walker, pointed at an authored array or `:json`
3
+ # `default:`/`example:` at class load — the same permittable_check_array
4
+ # and permittable_check_json a request goes through, so the two cannot
5
+ # drift apart. Two seams are overridden:
5
6
  #
6
7
  # * the field WHOSE default:/example: is being validated does not run
7
8
  # its own `transform:` — see permittable_transform below for exactly
@@ -26,6 +27,10 @@ module Permittable
26
27
  new.read_array(field, value, as_sent: as_sent)
27
28
  end
28
29
 
30
+ def self.read_json(field, value)
31
+ new.read_json(field, value)
32
+ end
33
+
29
34
  def self.summary(violations)
30
35
  new.summary(violations)
31
36
  end
@@ -40,6 +45,15 @@ module Permittable
40
45
  [read, violations]
41
46
  end
42
47
 
48
+ # [:ok, value as a request would get it] or [:error, code]. A request's
49
+ # hash reaches permittable_check_json indifferent, as every Hash inside
50
+ # the walker's params is, so the authored one is made so first, the way
51
+ # the walker reads a nested hash. permittable_check_json then hands
52
+ # `validate:` a deep copy, as it does a request's, and returns that copy.
53
+ def read_json(field, value)
54
+ permittable_check_json(field, ActiveSupport::HashWithIndifferentAccess.new(value))
55
+ end
56
+
43
57
  def summary(violations)
44
58
  permittable_violation_summary(violations)
45
59
  end
@@ -28,6 +28,15 @@ module Permittable
28
28
  date: :temporal, datetime: :temporal, time: :temporal, timestamp: :temporal, timestamptz: :temporal
29
29
  }.freeze
30
30
 
31
+ # The contract type an `array ..., of:` takes for a column element type,
32
+ # for the array-column hint. Only scalar element types: `of:` takes no
33
+ # other, so a jsonb[] column gets the hint without an `of:`.
34
+ ARRAY_ELEMENT_TYPES = {
35
+ string: :string, text: :string, citext: :string, uuid: :string,
36
+ integer: :integer, bigint: :integer, float: :float, decimal: :decimal, boolean: :boolean,
37
+ date: :date, datetime: :datetime, timestamp: :datetime, timestamptz: :datetime
38
+ }.freeze
39
+
31
40
  module_function
32
41
 
33
42
  # `types:` teaches the error message: a Symbol/String applies to every
@@ -60,6 +69,8 @@ module Permittable
60
69
  column = klass.columns_hash[field.to_s]
61
70
  return unless declared && column
62
71
 
72
+ ensure_not_array_column!(label, klass, field, declared, column)
73
+
63
74
  wanted = TYPE_GROUPS[declared.to_sym]
64
75
  enum = enum_attribute?(klass, field)
65
76
  # Checked before the column's type, which an enum's contract does not
@@ -92,6 +103,24 @@ module Permittable
92
103
  "(table: #{klass.table_name}). #{fix}"
93
104
  end
94
105
 
106
+ # A PostgreSQL array column reports its ELEMENT type as `type`, so
107
+ # `optional :tags, :string` over `t.string :tags, array: true` sits in
108
+ # the same group as the column and the group check passes — yet the
109
+ # field rejects as invalid_type every array the column stores. Only a
110
+ # scalar or :json field reaches this guard (an `array` field is
111
+ # implicitly virtual), and neither accepts an Array, so any declaration
112
+ # over an array column is the wrong shape.
113
+ def ensure_not_array_column!(label, klass, field, declared, column)
114
+ return unless column.respond_to?(:array?) && column.array?
115
+
116
+ element = ARRAY_ELEMENT_TYPES[column.type&.to_sym]
117
+ spelled = element ? "array :#{field}, of: :#{element}" : "array :#{field}"
118
+ raise ArgumentError,
119
+ "#{label}: '#{field}' is declared :#{declared} but the column is an array#{" (of :#{column.type})" if column.type} " \
120
+ "(table: #{klass.table_name}), so every array it stores would be rejected as invalid_type. " \
121
+ "Declare it `#{spelled}`, or declare the field virtual: true if it is not backed by this column."
122
+ end
123
+
95
124
  # `model:` is only duck-typed on column_names, hence the respond_to?.
96
125
  def enum_attribute?(klass, field)
97
126
  klass.respond_to?(:defined_enums) && klass.defined_enums.key?(field.to_s)
@@ -108,7 +108,10 @@ module Permittable
108
108
  # The contract's fields, which is also what makes a Contract usable as a
109
109
  # field group: `use SomeContract` inside a permit_params block splices
110
110
  # them in, so a webhook payload and a controller action can share one
111
- # definition instead of two that drift.
111
+ # set of fields instead of two that drift. Only the fields — `use`
112
+ # refuses a contract that declares a finalize, which no field carries.
113
+ # To share both, put the fields in a `Permittable.fields` group that
114
+ # each side uses, and declare finalize in each.
112
115
  def fields
113
116
  rule[:fields]
114
117
  end
@@ -3,7 +3,8 @@ module Permittable
3
3
  #
4
4
  # `:envelope` (the default) is the gem's original shape: the host's
5
5
  # #render_error when the controller defines one (e.g. concerns_on_rails'
6
- # Respondable), otherwise the identical inline JSON.
6
+ # Respondable; see host_render_error for a private one), otherwise the
7
+ # identical inline JSON.
7
8
  #
8
9
  # `:problem` renders RFC 9457 Problem Details — `application/problem+json`
9
10
  # with type/title/status/detail/instance members and the field violations as
@@ -44,8 +45,9 @@ module Permittable
44
45
  end
45
46
 
46
47
  def render_envelope(controller, message:, status:, code: nil, details: nil)
47
- if controller.respond_to?(:render_error)
48
- host = host_method(controller)
48
+ public_host = controller.respond_to?(:render_error)
49
+ host = public_host ? host_method(controller) : private_host_method(controller)
50
+ if public_host || host
49
51
  kwargs = { message: message, code: code, status: status }
50
52
  kwargs[:errors] = details if details && (host.nil? || takes_errors?(host))
51
53
  host ? host.call(**kwargs) : controller.render_error(**kwargs)
@@ -83,17 +85,50 @@ module Permittable
83
85
  !type.nil? && (type == "application/json" || type.end_with?("+json"))
84
86
  end
85
87
 
86
- # The Method for the host's render_error, or nil when the controller claims
87
- # to respond (respond_to? is true) yet has no such method to inspect: one
88
- # served by method_missing without respond_to_missing?. Master called it
89
- # straight through with errors: included, and nil keeps doing exactly that
90
- # rather than raising NameError out of the rescue_from handler.
88
+ # The Method for the host's public render_error, or nil when the controller
89
+ # claims to respond (respond_to? is true) yet has no such method to
90
+ # inspect: one served by method_missing without respond_to_missing?.
91
+ # Master called it straight through with errors: included, and nil keeps
92
+ # doing exactly that rather than raising NameError out of the rescue_from
93
+ # handler.
91
94
  def host_method(controller)
92
95
  controller.method(:render_error)
93
96
  rescue NameError
94
97
  nil
95
98
  end
96
99
 
100
+ # A private render_error is the usual spelling on an ApplicationController
101
+ # (a public method there is routable as an action in every subclass), so
102
+ # it is used too — but only when it is shaped like the documented keyword
103
+ # contract: a private `render_error(message, status)` helper written for
104
+ # something else was never consulted, and must not start turning every 422
105
+ # into a 500. Nil renders the inline shape.
106
+ def private_host_method(controller)
107
+ return nil unless controller.respond_to?(:render_error, true)
108
+
109
+ method = host_method(controller)
110
+ method if method && keyword_contract?(method.parameters)
111
+ end
112
+
113
+ # The keywords render_envelope may pass: message:, code: and status:
114
+ # always, errors: when the method takes it.
115
+ DELEGATED_KEYWORDS = %i[message code status errors].freeze
116
+
117
+ # Shaped like the documented contract: it takes message:, code: and
118
+ # status: (by name or through **), and requires nothing the gem does not
119
+ # pass — no positional argument, no keyword outside DELEGATED_KEYWORDS.
120
+ # `(message:, status:)` is a common private helper on an
121
+ # ApplicationController, and calling it with code: would raise.
122
+ def keyword_contract?(parameters)
123
+ return false if parameters.any? { |kind, _| kind == :req }
124
+
125
+ required = parameters.filter_map { |kind, name| name if kind == :keyreq }
126
+ return false unless (required - DELEGATED_KEYWORDS).empty?
127
+ return true if parameters.any? { |kind, _| kind == :keyrest }
128
+
129
+ (%i[message code status] - keyword_names(parameters)).empty?
130
+ end
131
+
97
132
  # A rejection always carries details, so passing errors: whenever there
98
133
  # are some passed it on every call — and a host documenting its contract
99
134
  # as `(message:, status:, code:)`, which the README promises keeps
@@ -25,7 +25,8 @@ module Permittable
25
25
  # use UserFields
26
26
  # end
27
27
  #
28
- # # PATCH: same fields, nothing mandatory.
28
+ # # PATCH: same fields, nothing mandatory, no defaults (a default
29
+ # # would reset plan to "free" on every PATCH that omits it).
29
30
  # permit_params :update, root: :user, model: User do
30
31
  # use UserFields, optional: true
31
32
  # end
@@ -43,7 +43,7 @@ module Permittable
43
43
  # lists the column. They ride on the column, set by in_rule, so the scan
44
44
  # path hands them to column_line without its own methods changing.
45
45
  DraftColumn = Struct.new(:name, :type, :null, :default, :default_function, :enum, :enum_integers, :sensitive,
46
- :unread, :rule, :listed, :assigned, keyword_init: true) do
46
+ :unread, :rule, :listed, :assigned, :array, :array_default, keyword_init: true) do
47
47
  def in_rule(rule, listed: false)
48
48
  self.class.new(**to_h, rule: rule, listed: listed)
49
49
  end
@@ -554,6 +554,8 @@ module Permittable
554
554
  def draft_column(model, column)
555
555
  DraftColumn.new(name: column.name, type: column.type, null: column.null,
556
556
  default_function: column.respond_to?(:default_function) && column.default_function,
557
+ array: column.respond_to?(:array?) && column.array? == true,
558
+ array_default: array_default?(column.default),
557
559
  sensitive: sensitive_role(model, column.name), assigned: assigned?(model, column),
558
560
  **model_facts(model, column))
559
561
  end
@@ -1084,9 +1086,11 @@ module Permittable
1084
1086
  # does not parse.
1085
1087
  def column_line(column)
1086
1088
  return "# TODO: #{column.name} #{omitted_note(column)}" if column.sensitive && !column.listed
1089
+ return array_column_line(column) if column.array
1087
1090
 
1088
1091
  type = column.enum ? :string : COLUMN_TYPES[column.type]
1089
1092
  return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
1093
+ return json_array_todo(column) if type == :json && column.array_default
1090
1094
 
1091
1095
  required = column.rule != :update && required_column?(column)
1092
1096
  line = "#{required ? 'required' : 'optional'} #{column.name.to_sym.inspect}, :#{type}"
@@ -1095,6 +1099,41 @@ module Permittable
1095
1099
  notes.empty? ? line : "#{line} # #{notes.join('; ')}"
1096
1100
  end
1097
1101
 
1102
+ # A PostgreSQL array column (`t.string :tags, array: true`) reports its
1103
+ # ELEMENT type as `type` and says it is an array through `array?`. Drafted
1104
+ # as a scalar, it would reject as invalid_type the very array it stores
1105
+ # the moment the draft is enforced. `of:` takes only a scalar type, so a
1106
+ # json/jsonb/hstore element type (an array of documents) is left as a
1107
+ # TODO, as is any element type with no contract type at all.
1108
+ def array_column_line(column)
1109
+ type = COLUMN_TYPES[column.type]
1110
+ unless type && type != :json
1111
+ return "# TODO: #{column.name} (#{column.type}[]) has no contract type — declare it as a nested block or an array"
1112
+ end
1113
+
1114
+ required = column.rule != :update && required_column?(column)
1115
+ line = "array #{column.name.to_sym.inspect}, of: :#{type}#{', required: true' if required}"
1116
+ notes = [column.default, *column_todos(column), "TODO: declare length: — an array without one is unbounded"]
1117
+ "#{line} # #{notes.compact.join('; ')}"
1118
+ end
1119
+
1120
+ # :json accepts only an object, but a json/jsonb column can as well hold
1121
+ # an array — and when its default is one, it does: drafted as :json, it
1122
+ # rejected as invalid_type, once enforced, what every client sends for
1123
+ # it. Whether the elements are scalars or objects the column cannot say,
1124
+ # so it is a TODO naming both declarations rather than a guess.
1125
+ def json_array_todo(column)
1126
+ name = column.name.to_sym.inspect
1127
+ "# TODO: #{column.name} (#{column.type}) defaults to an array, which :json refuses — declare it " \
1128
+ "`array #{name}, of: :string` (confirm the element type) or `array #{name} do ... end` for objects"
1129
+ end
1130
+
1131
+ # A database default as the adapter reports it: the SQL literal "[]"
1132
+ # through Rails 8.0, possibly the cast Array after.
1133
+ def array_default?(default)
1134
+ default.is_a?(Array) || (default.is_a?(String) && default.lstrip.start_with?("["))
1135
+ end
1136
+
1098
1137
  # Drafting from columns alone, a sensitive column is left out of the
1099
1138
  # fields — nothing says any client sends it — and named here instead.
1100
1139
  # The lock_version note says where to declare it in the rules actually
@@ -391,16 +391,28 @@ module Permittable
391
391
  # length: reasons about characters on strings and element count on
392
392
  # arrays; either way it is an exact Integer or a Range (possibly endless
393
393
  # / beginless, possibly exclusive).
394
+ # An infinite end (`1..Float::INFINITY`) is no bound, as for numeric
395
+ # `in:` — and JSON has no Infinity, so exporting it raised.
394
396
  def length_bounds(spec)
395
397
  case spec
396
398
  when Integer then [spec, spec]
397
399
  when Range
398
- max = spec.end && spec.exclude_end? ? spec.end - 1 : spec.end
399
- [spec.begin, max]
400
+ min = finite_length(spec.begin)
401
+ max = finite_length(spec.end)
402
+ max -= 1 if max && spec.exclude_end?
403
+ [min, max]
400
404
  else [nil, nil]
401
405
  end
402
406
  end
403
407
 
408
+ # A whole-number Float/Rational end (`1..10.0`) is published as the
409
+ # Integer it means: minLength and friends are counts.
410
+ def finite_length(value)
411
+ return nil if value.nil? || (value.is_a?(Float) && value.infinite?)
412
+
413
+ value.to_i
414
+ end
415
+
404
416
  # Documentation keys shared by every field kind. `default:`/`example:`
405
417
  # are stored as the contract casts them (Date, Time, BigDecimal), so they
406
418
  # are re-encoded as JSON scalars.