Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
- **A CI matrix over the whole supported range.** The suite now runs against activesupport 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 across the supported Rubies, via one pinned gemfile per line in [`gemfiles/`](gemfiles/README.md) (no new dev dependency — plain `BUNDLE_GEMFILE`). The matrix is an explicit include list rather than a cross product, so it doubles as the answer to "which combinations are actually supported?". Variant lockfiles are deliberately not committed: each run resolves the newest patch of its line, so a regression in a supported version fails CI instead of being frozen out by a stale lock.
- **A `runtime-deps` CI job that proves activesupport is the only runtime dependency.** The spec suite can't: it bundles actionpack and activerecord to exercise the integration and schema-drift paths. This job installs the *built gem* with nothing but its declared dependencies, asserts actionpack/activerecord/rails are genuinely absent, and then exercises every controller-free surface — standalone contracts (casting, defaults, `finalize`, 400/422 semantics, nested plain hashes), the concern on a plain params duck, `unknown: :error` with no logger to warn through, `sensitive:` registration with no Railtie, monitor mode, instrumentation, JSON Schema, OpenAPI assembly, and the generator. Every `respond_to?`/`defined?` guard in the gem is a promise; a missing one now fails CI.
- **`spec/schema_conformance_spec.rb` — the "docs cannot drift" claim is now tested rather than argued.** Every other spec checks one side or the other; this one checks that the two **agree**, walking canonical JSON payloads through both a contract and its own exported schema and comparing the verdicts. It covers scalars and bounds, enums, exclusive and endless ranges, exact lengths, formats, arrays of scalars and of hashes, nested hashes under `unknown: :error`, rooted contracts, `nullable:` fields, and opaque `:json` fields with bounds. `spec/support/tiny_json_schema.rb` is a deliberately small validator covering exactly the keywords the exporter emits and nothing else — a measuring instrument, not a dependency — and one example asserts the exporter emits no keyword the validator silently ignores, so the two cannot fall out of step. The spec was mutation-tested: dropping `maxItems`, `additionalProperties: false`, the required-string `minLength: 1`, or the `root:` required wrapper each makes it fail.
- **Every place the schema and the runtime differ is now labelled, and its direction asserted.** Two go the safe way — the server accepts what the docs reject, so a client following the docs is merely conservative: non-canonical encodings (`"30"` for an `:integer`, `1` for a `:string`, because form and query payloads are all strings), and an explicit `null` read as absence on a field that is not `nullable:`. One goes the other way and is now stated plainly instead of being left to be discovered: a `:json` field's `max_depth:` is a bound JSON Schema has no keyword for, so the published document is **looser** than the server there and an over-nested payload still earns a 422. The bound is exported as `x-permittable-max-depth` rather than dropped, and the spec asserts that extension is present. A new divergence in either direction fails the suite rather than shipping quietly.
No behaviour changes: this release adds tests and documentation only.
- **Every place the schema and the runtime differ is now labelled, and its direction asserted.** Two go the safe way — the server accepts what the docs reject, so a client following the docs is merely conservative: non-canonical encodings (`"30"` for an `:integer`, `1` for a `:string`, because form and query payloads are all strings), and an explicit `null` read as absence on a field that is not `nullable:`. The ones that go the other way are now stated plainly instead of being left to be discovered, starting with a `:json` field's `max_depth:`: a bound JSON Schema has no keyword for, so the published document is **looser** than the server there and an over-nested payload still earns a 422. The bound is exported as `x-permittable-max-depth` rather than dropped, and the spec asserts that extension is present. (The rest of the looser list — and a third safe case — are under **Fixed**.) A new divergence in either direction fails the suite rather than shipping quietly.
- **`permittable:generate` now reads Rails 8 `params.expect` calls, not just `params.permit`.** The generator is the adoption on-ramp, and it was blind to the syntax Rails 8 apps actually use — a modern controller has no permit calls to scan, so the draft fell back to columns alone and lost everything the app already knew about its own params (the `root:`, the permitted key list, which keys aren't columns). `params.expect(user: [:name, tag_names: [], address: [:city], line_items: [[:sku]]])` is now scanned into the same `Scan`, merged with any permit calls in the same controller.
- **Arrays of hashes are drafted without a TODO when the source says so.** `expect` distinguishes what `permit` cannot: `key: [:a]` is a nested hash, `key: [[:a]]` is an array of hashes. A scanned `[[...]]` therefore drafts as `array :key do ... end` with no "this may be an array of hashes" TODO, and `Scan` carries the new `nested_arrays` member alongside `nested`.
- **Route params are not mistaken for fields.** In `params.expect(:id, user: [:name])` the `:id` is a routing key, not body input, so it stays visible in a TODO rather than being drafted as a contract field — as does a second envelope (`params.expect(user: [...], address: [...])`), which belongs under a different `root:` than one rooted contract can express. A rootless `params.expect(:q, :page)` still drafts as scalars, since there is no envelope to be a sibling of.
Expand Down Expand Up @@ -69,6 +68,19 @@ Contracts that don't opt in are byte-for-byte unaffected: the default format is
- **`not_to permit_param` passed on keys the contract lets through.** A path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`) was reported as undeclared, although `:json` accepts any nested key; the negated form now fails, and the positive form passes (refusing qualifiers it cannot check, since nothing about the nested key is declared). A path deeper than the field's `max_depth:` is still not permitted, counting each key and `[n]` step the way the runtime does. Paths in the runtime's own violation form, `"line_items[0].sku"`, now resolve instead of passing negated as undeclared. A path repeating the `root:` (`"user.email"` under `root: :user`) passed negated because paths are relative to the root; when the rest of the path resolves, both forms now fail with `paths are relative to root: :user, so write permit_param(:email)`.
- **A malformed path crashed the matcher.** `permit_param("")` raised `NoMethodError`, and `"a."` was silently read as `"a"`; an empty path or empty segment now raises an `ArgumentError` naming the path.
- **`as_array(of: :string)` on an array of hashes printed `but it is of: :`.** It now says `:line_items is an array of hashes`, and `as(:string)` on one no longer suggests `as_array(of: ...)`, which cannot apply to it. On a field that is not an array at all, the redundant empty `of:` line is gone; the `expected an array field` line already says it.
- **A `:decimal` bounded by BigDecimals exported an invalid schema.** `in: BigDecimal("0.01")..BigDecimal("999.99")` — the natural way to bound a price — published `"minimum": "0.01", "maximum": "999.99"`, because range bounds went through the same re-encoding as an authored `default:`, which renders a BigDecimal as its precision-safe string. The metaschema requires `minimum`/`maximum` to be numbers, so the whole document failed validation. Bounds are now JSON numbers: an Integer when the bound is one, otherwise a Float. Where the nearest double would let through a value the server refuses (possible only past 15 significant digits — never a price), the bound moves **inward**, a minimum up and a maximum down, one double at a time until the field's own cast and comparison accept the most extreme value the docs admit. That check matters because the right distance depends on the type: a `:decimal` compares exactly, while a `:float` compares through BigDecimal's lossy reading of a Float and needs a few doubles more. So the published range is always exact as written and can only be narrower than the enforced one, on either type — a caveat worth stating plainly: a client that parses the published JSON with ordinary double-precision floats, rather than reading the digits as sent, can still round a value across the boundary. That is inherent to how a double reads any JSON number and is not something this exporter can fix from the document side.
**An INTEGRAL bound is exact at any magnitude**, `10**400` included: a JSON number literal has no size limit, so `optional :x, :float, in: (10**400..)` now publishes `minimum: 10**400` outright, matching master, instead of vanishing. (It briefly did vanish in an earlier draft of this fix: the inward-nudge above routes a candidate bound through `to_f` to ask the field's own cast whether it is honoured, which itself overflows a bound this large to `Infinity`, walking the bound to `Infinity` and dropping it — looser than master, not a labelled divergence.) A genuinely **fractional** bound past `Float::MAX` (`BigDecimal("1e400") + BigDecimal("0.5")`) has no such escape — there is no arbitrary-precision JSON number this exporter emits without going through `Float` — and stays omitted, same as an infinite bound.
An infinite bound (`Float::INFINITY`, `BigDecimal("Infinity")`) means no bound and is omitted, as is a NaN one; neither is a JSON number. `default:`/`example:` keep their string encoding, which `:decimal`'s type permits.
- **Most of the places the docs are looser than the server were unlabelled.** Only `max_depth:` was listed. Now the README, the exporter's own comment and `spec/schema_conformance_spec.rb` also name the other five — `LOOSER` in the spec, six keys in all — the spec labels each, asserts its direction, and checks that the rule is still visible on the diverging field's own schema, so none can pass as agreement:
- **`normalize:` runs before the checks.** With `length: 3..10, normalize: :squish`, `" "` passed the docs and was `missing` on the server, and `" a "` passed them and failed `length`. The step was not exported at all; it is now `x-permittable-normalize` — the preset's name, or `true` for a custom proc. (It also runs the safe way — `" abcdefghij "` is too long for the docs and fine once squished — which is listed with the safe cases.)
- **A bounded `:decimal` sent as a string** skips `minimum`/`maximum`, which constrain only numbers, so `"5000"` passed the docs for a bound the server enforces.
- **Strings whose validity is a `format`.** `"abc"` or `"NaN"` for a `:decimal`, and `"2026-02-30"` for a `:date` or `:datetime`, pass the docs — `format` is an annotation in draft 2020-12 — and fail the cast.
- **A `validate:` proc** is opaque app code, so the schema can only flag it — `x-permittable-custom-validation` — never enforce what it checks.
- **A Range of non-numbers**, such as `in: "a".."m"`, has no `minimum`/`maximum` equivalent and rides along as `x-permittable-range` instead.

An untranslatable `format:` regexp (`x-permittable-pattern`) is a seventh case in the same spirit — a value it would refuse still passes the docs — but it is **not** one of the spec's six: [#66](https://github.com/VSN2015/permittable/pull/66), reworking the Ruby → ECMA-262 `pattern` translation itself, owns adding its conformance case, so this PR only lists it as a known gap rather than asserting a direction for code it does not touch.

No runtime behaviour changes to the request/response path — this is what the schema *says*, not what the server enforces.

## 0.8.0 (2026-09-19)
<!-- title: a no-op sensitive: cascade, an invalid OpenAPI export, and megabyte-scale rejections -->
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -985,16 +985,22 @@ Output is deterministic (fixed key order, declaration-order properties), so the

`spec/schema_conformance_spec.rb` holds the "cannot drift" claim to account: it walks canonical JSON payloads through both the contract and its own exported schema and asserts the verdicts agree.

Where they legitimately differ, the spec names the reason and asserts the **direction**, so a new divergence fails the suite instead of shipping quietly. Two cases go the safe way — the **server accepts what its docs reject**, leaving a client that follows the docs merely conservative:
Where they legitimately differ, the spec names the reason and asserts the **direction**, so a new divergence fails the suite instead of shipping quietly. Three cases go the safe way — the **server accepts what its docs reject**, leaving a client that follows the docs merely conservative:

- **Non-canonical encodings.** Coercion accepts `"30"` for an `:integer` and `1` for a `:string`, because form and query payloads are all strings. The schema documents the canonical JSON encoding only.
- **`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.
- **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.

One case goes the other way, and is worth knowing before you hand the document to a client:
Six cases go the other way, and are worth knowing before you hand the document to a client. Each rule stays visible on its own field, and the spec asserts that as well as the direction:

- **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.
- **`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.
- **A bounded `:decimal` sent as a string.** A `:decimal` is documented as `["string", "number"]`, because the string is its precision-safe encoding, but `minimum`/`maximum` constrain only numbers — so `"5000"` passes the docs for `in: BigDecimal("0.01")..BigDecimal("999.99")` and the server answers `inclusion`. The bound is still published, as a JSON number, for a client that parses the string first. (Numbers are published exactly as written, at any magnitude — `10**400` included, since a JSON integer has no size limit — but a client that reads the document back with ordinary double-precision floats, rather than the digits as sent, can still round a value across a boundary. That is inherent to parsing any JSON number as a double, not something this exporter controls.)
- **A `validate:` proc.** It is opaque app code, so the schema can only flag it — `x-permittable-custom-validation` — never enforce what it checks. A value the proc refuses still passes the docs.
- **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.
- **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`.

Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.
A `format:` regexp that does not translate to ECMA-262 is looser in the same way — it publishes as `x-permittable-pattern` rather than a `pattern` that would enforce something else, so a value it refuses still passes the docs — but it is not one of the six above: `spec/schema_conformance_spec.rb` does not yet assert a case for it, since the Ruby → ECMA-262 translation it would depend on is being reworked separately. Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.


<details>
Expand Down
Loading
Loading