permittable 0.9.0 → 0.10.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 +18 -0
- data/README.md +8 -6
- data/lib/permittable/authored_values.rb +24 -8
- data/lib/permittable/generator.rb +22 -3
- data/lib/permittable/json_schema.rb +28 -4
- data/lib/permittable/open_api.rb +9 -1
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +56 -9
- 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: e135a1605d2d98e7c3a3952e2f9dc47394a656249f52d22b7018ed5dc15b23cb
|
|
4
|
+
data.tar.gz: f3748c6014a1f74099719fc8c42af289910c9f94d36687837cbe409fd67538a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 12881e40e20e390c7eb93963a5ca89aa402ed58d79eb0bfa7383d7a2ea6c5b93f6d61d1b35c94ed27b3b75774ddc75256e1d176f9cea5470d06200f64aaecb7e
|
|
7
|
+
data.tar.gz: 312bd2b824147af2821dc318c0a66a250d65bd7626d80574e91a0bfb5cc06159a0e22428e30b6d463bfe57ece1fe72c56b4ca8b513b7cdbde1419eb637cd0533
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.10.0 (2026-09-30)
|
|
4
|
+
<!-- title: canonical numeric strings, the app's own CSRF param, and defaults that agree with requests -->
|
|
5
|
+
|
|
6
|
+
Ten fixes and no new surface. Three change what an existing contract does. A numeric string must now be spelled canonically: `Integer()`, `Float()` and `BigDecimal()` all read underscore separators and surrounding whitespace, so `"1_8"` was eighteen and `" 99 "` was ninety-nine, and both are now `invalid_type`. An array `default:` now runs its sub-fields' `transform:` when the contract loads, so a request that omits the field and one that sends the default's value hand the action the same thing. And an app that renamed its CSRF parameter with `config.action_controller.request_forgery_protection_token` stops failing every form POST under `unknown: :error`. Alongside them: a `sensitive:` field no longer publishes its own `default:`/`example:` in the exported schema, a `message:` shared through `use` can no longer be rewritten from one contract into another, `permittable:generate` stops drafting an enum accessor the model does not answer to and stops hiding a real error behind an empty draft, and three pieces of process-wide state — the error-response schemas, the scalar JSON Schema entries and the `sensitive:` sink list — can no longer be corrupted by a caller or a concurrent class load.
|
|
7
|
+
|
|
8
|
+
Minor rather than patch: the API is untouched, but two fixes are visible from outside. A client that pads a number with whitespace or underscores now gets a 422 where it used to get the number, and an array `default:` whose sub-fields declare `transform:` is now handed out transformed — that `transform:` is app code, and it now runs when the contract loads. Read the first and third entries under **Fixed** before upgrading if either applies to your contracts.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **`:integer`, `:float` and `:decimal` read `"1_8"` as eighteen and `" 99 "` as ninety-nine.** The casts delegate to `Integer()`, `Float()` and `BigDecimal()`, which all accept underscore digit separators and surrounding whitespace — a convenience for a number literal in Ruby source, not for a request body, and the same kind of leniency the NaN/Infinity and underflow checks already close off through other doors. A numeric string is now checked against a canonical form before anything parses it: an optional sign, digits, an optional fraction and an optional exponent — `"-12"`, `"007"`, `".5"`, `"1.5e10"`, and `"1e400"` for `:decimal` all still cast — and anything else is `invalid_type`. `normalize:` is a `:string`-only option, so there is no in-contract way to keep the old whitespace tolerance; a client that pads a number has a formatting bug upstream, which is now reported rather than repaired.
|
|
12
|
+
- **A CSRF parameter renamed with `request_forgery_protection_token` failed every form POST under `unknown: :error`.** The top-level exemption hard-coded Rails' default `authenticity_token`, so an app that renamed it got `{ param: "csrf_token", code: "unknown" }` on every ordinary form submission — exactly the failure the exemption exists to prevent, spelled with the app's own key. The controller's configured token name is now read fresh on each request (it can vary per controller, and Rails may not have finished initializing when the gem loads) and exempted alongside the fixed keys. A plain params duck and a standalone `Contract` have no such setting and are unchanged, and so is monitor mode's raw pass-through.
|
|
13
|
+
- **An array `default:` skipped its sub-fields' `transform:`, so an omitted field and an explicitly-sent identical value diverged.** An authored array default is walked by the same code a request goes through, but that walk suppressed `transform:` on *every* field it visited rather than only the array's own. With `array :line_items, default: [{ "price" => "10.00" }] do optional :price, :decimal, transform: ->(v) { v * 100 } end`, a request omitting `line_items` got `price: 10` and one sending `[{ "price" => "10.00" }]` got `price: 1000`, and the exported OpenAPI `default` documented a value the server never produced. A sub-field's own `transform:` now runs over the default when the contract loads, so the stored value is what an equivalent request yields. The array field's **own** `transform:` still never runs over its default, exactly as `default:` documents, and the `with_default` matcher reads its expected array through the same walk, so it stays in agreement.
|
|
14
|
+
- **A field's `message:` was never deep-frozen, so two contracts sharing it through `use` could corrupt each other.** `default:`, `example:` and `in:` are deep-copied and deep-frozen on declaration; `message:` was frozen only at the top level in its Hash form and not at all as a String. `Permittable.fields` splices a group's field hashes into each contract by reference, so `msg << " NOW"` on one contract's violation message rewrote the message every other contract using that group would render, for the life of the process. Both forms now go through the same copy-then-freeze as the other authored values.
|
|
15
|
+
- **A `sensitive:` field's `default:`/`example:` were exported in the clear.** The field was marked `writeOnly` and registered for log redaction, and then the exported JSON Schema and OpenAPI document — the one output meant to leave the app, for client generators or a public docs endpoint — carried the real value under `default` and `examples`. Both keys are now omitted for a sensitive field, a child inheriting the cascade included; `description` is still exported, being authored prose rather than the value.
|
|
16
|
+
- **`permittable:generate` drafted `Model.statuses.keys` for an accessor the model does not have.** The generator only checked that the pluralised enum name was a valid identifier, so a renamed or excluded accessor produced a draft that raised `NoMethodError` the moment it loaded — while the schema-drift guard's own error messages already made the safer choice. The draft now also checks `respond_to?` and otherwise spells the list as `Model.defined_enums["status"].keys`, which is always callable.
|
|
17
|
+
- **`permittable:generate` swallowed every error while reading a model's columns and drafted an empty contract instead.** The rescue was a blanket `StandardError`, so a broken custom type adapter, or any plain bug, quietly produced a degraded draft with nothing to say why. It is now scoped to `ActiveRecord::ActiveRecordError`, the same rule `ColumnGuard.schema_reachable?` follows: a genuinely unreachable schema (no database yet, table not migrated) still degrades to "no columns", and anything else propagates.
|
|
18
|
+
- **`OpenAPI.document` handed out its error-response schemas by reference, so a caller editing its own document rewrote them for every document generated afterward.** `ERROR_SCHEMA` and `PROBLEM_SCHEMA` are frozen, but Ruby's `freeze` is shallow: assigning into a nested level of a generated document raised nothing and permanently changed the constant. Each document now gets a deep copy. The JSON Schema exporter had the same shape of exposure — a `SCALAR_SCHEMAS` entry was shallow-copied, so every `:decimal` schema shared one live `type` Array with the constant — and now deep-copies too. Nothing inside the gem mutated either in place; this closes the door on a caller doing so.
|
|
19
|
+
- **`sensitive:` sink callbacks were iterated without the lock they are appended under.** `Permittable.on_sensitive_parameter` appends to the sink list under `@registry_mutex`; a contract declaring a `sensitive:` field iterated that same list without it. The GVL hides this on MRI, but on JRuby or TruffleRuby a contract class-loading while a sink is installed is a concurrent mutation during iteration — a raise, or a sink call silently skipped, which is a parameter name that never reaches `config.filter_parameters`. The list is now snapshotted under the mutex and the callbacks run against the snapshot, outside it, so a sink that calls back into the gem cannot deadlock.
|
|
20
|
+
|
|
3
21
|
## 0.9.0 (2026-09-27)
|
|
4
22
|
<!-- title: params.expect drafting, audit coverage, field groups, and a rewritten pattern translator -->
|
|
5
23
|
|
data/README.md
CHANGED
|
@@ -297,7 +297,7 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
297
297
|
| `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
|
|
298
298
|
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
|
|
299
299
|
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
|
|
300
|
-
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. Either way it is frozen, and each request gets its own deep copy |
|
|
300
|
+
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth with its sub-fields' own `transform:` applied. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. Either way it is frozen, and each request gets its own deep copy |
|
|
301
301
|
| `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code. On an array it runs only when no element violated — an undeclared key inside an element does not count — and `transform:` runs only when nothing violated at all |
|
|
302
302
|
| `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
|
|
303
303
|
| `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
|
|
@@ -359,9 +359,9 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
359
359
|
| Type | Accepts | Rejects (`invalid_type`) |
|
|
360
360
|
|---|---|---|
|
|
361
361
|
| `: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"`) |
|
|
362
|
-
| `:integer` | `Integer`; whole `Float`s (`4.0`); base-10 numeric strings | `"4.5"`, `"abc"`, `4.5`, NaN/Infinity |
|
|
363
|
-
| `:float` | `Numeric`;
|
|
364
|
-
| `:decimal` | `Numeric
|
|
362
|
+
| `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
|
|
363
|
+
| `:float` | `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "` |
|
|
364
|
+
| `:decimal` | `Numeric`, or a canonical numeric string → `BigDecimal` | Unparseable strings, `"1_000"`, `" 1.5 "` |
|
|
365
365
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
366
366
|
| `: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"`) |
|
|
367
367
|
| `: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"`) |
|
|
@@ -373,6 +373,8 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
373
373
|
|
|
374
374
|
**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.
|
|
375
375
|
|
|
376
|
+
**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`.
|
|
377
|
+
|
|
376
378
|
Two more behaviours worth committing to memory:
|
|
377
379
|
|
|
378
380
|
- **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.
|
|
@@ -572,7 +574,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
|
|
|
572
574
|
|
|
573
575
|
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.
|
|
574
576
|
|
|
575
|
-
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token
|
|
577
|
+
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 `commit` from an ordinary form POST. All seven are exempt at the top level. 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. **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.
|
|
576
578
|
|
|
577
579
|
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.
|
|
578
580
|
|
|
@@ -1104,7 +1106,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1104
1106
|
| nested block / `array` | `object` + `properties` / `array` + `items` |
|
|
1105
1107
|
| `unknown: :error` | `additionalProperties: false`, at every nesting level |
|
|
1106
1108
|
| `root:` | the wrapping object, itself required |
|
|
1107
|
-
| `sensitive: true` | `writeOnly: true` (never echoed in responses) |
|
|
1109
|
+
| `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published |
|
|
1108
1110
|
|
|
1109
1111
|
</details>
|
|
1110
1112
|
|
|
@@ -3,12 +3,11 @@ module Permittable
|
|
|
3
3
|
# at class load — the same permittable_check_array a request goes
|
|
4
4
|
# through, so the two cannot drift apart. Two seams are overridden:
|
|
5
5
|
#
|
|
6
|
-
# *
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# already checked with.
|
|
6
|
+
# * the field WHOSE default:/example: is being validated does not run
|
|
7
|
+
# its own `transform:` — see permittable_transform below for exactly
|
|
8
|
+
# which field that is and why. A SUB-FIELD's own transform: still
|
|
9
|
+
# runs, so what gets stored is what an equivalent request would
|
|
10
|
+
# produce.
|
|
12
11
|
# * violations carry no `message:` — they become an ArgumentError for
|
|
13
12
|
# the contract's author, not a response for a client, and I18n may not
|
|
14
13
|
# be loaded yet.
|
|
@@ -26,6 +25,7 @@ module Permittable
|
|
|
26
25
|
# [value as a request would get it, violations]
|
|
27
26
|
def read_array(field, value)
|
|
28
27
|
violations = []
|
|
28
|
+
@field = field
|
|
29
29
|
read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations)
|
|
30
30
|
[read, violations]
|
|
31
31
|
end
|
|
@@ -36,8 +36,24 @@ module Permittable
|
|
|
36
36
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# Suppresses transform: for the field WHOSE default/example is being
|
|
40
|
+
# authored-validated (@field, compared by identity) — never for a
|
|
41
|
+
# sub-field nested inside it. A sub-field's own transform: is app code
|
|
42
|
+
# too, but it belongs to a DIFFERENT field's contract: an equivalent
|
|
43
|
+
# request sending that sub-field's value would run it, so the stored
|
|
44
|
+
# default has to match, or an omitted field and an explicitly-sent
|
|
45
|
+
# identical value silently diverge (and the exported OpenAPI default,
|
|
46
|
+
# which reads this same value, documents one the server never produces).
|
|
47
|
+
#
|
|
48
|
+
# When @field itself has a transform:, its result is discarded anyway
|
|
49
|
+
# (validate_array_authored_value! stores the value AS AUTHORED instead —
|
|
50
|
+
# see its comment), so nothing inside its subtree is worth reading
|
|
51
|
+
# transformed: suppressing every nested call too keeps that walk free of
|
|
52
|
+
# app code, exactly as a scalar default's cast-only check already is.
|
|
53
|
+
def permittable_transform(field, value)
|
|
54
|
+
return value if @field[:transform] || field.equal?(@field)
|
|
55
|
+
|
|
56
|
+
super
|
|
41
57
|
end
|
|
42
58
|
|
|
43
59
|
def permittable_violation(_field, param, code)
|
|
@@ -400,6 +400,15 @@ module Permittable
|
|
|
400
400
|
# top (draft_column) must not share this rescue: an error there would
|
|
401
401
|
# otherwise discard EVERY column — the draft silently falls back to a
|
|
402
402
|
# scan alone, or to nothing — for what is one column's problem.
|
|
403
|
+
#
|
|
404
|
+
# The rescue is scoped to ActiveRecord::ActiveRecordError, same as
|
|
405
|
+
# ColumnGuard.schema_reachable? and for the same reason: a genuinely
|
|
406
|
+
# unreachable schema (no database yet, table not migrated) degrades to
|
|
407
|
+
# no columns, but a real bug — a broken custom type adapter, a NameError
|
|
408
|
+
# from a typo — must keep surfacing instead of quietly emitting an empty
|
|
409
|
+
# draft. The defined? guard keeps this gem loadable without
|
|
410
|
+
# activerecord, same as schema_reachable? (a host without it duck-types
|
|
411
|
+
# `model:` and cannot raise an ActiveRecordError in the first place).
|
|
403
412
|
def schema_columns(model)
|
|
404
413
|
return nil unless model.respond_to?(:columns)
|
|
405
414
|
return nil unless model.table_exists?
|
|
@@ -408,7 +417,9 @@ module Permittable
|
|
|
408
417
|
# into its column names; a nil primary key becomes [].
|
|
409
418
|
skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
|
|
410
419
|
model.columns.reject { |c| skipped.include?(c.name) }
|
|
411
|
-
rescue StandardError
|
|
420
|
+
rescue StandardError => e
|
|
421
|
+
raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
|
|
422
|
+
|
|
412
423
|
nil
|
|
413
424
|
end
|
|
414
425
|
|
|
@@ -507,7 +518,11 @@ module Permittable
|
|
|
507
518
|
# can be CALLED as `Model.name`: a `first-status` enum's
|
|
508
519
|
# `Order.first-statuses.keys` parses as `Order.first - statuses.keys`,
|
|
509
520
|
# which runs a query when the draft loads. defined_enums reaches the
|
|
510
|
-
# same mapping by name.
|
|
521
|
+
# same mapping by name. The model is also checked for the accessor
|
|
522
|
+
# itself (mirrors ColumnGuard.enum_keys_expr): a name can pluralize to a
|
|
523
|
+
# valid identifier that the model does not actually answer to — renamed
|
|
524
|
+
# or otherwise excluded — and calling it would raise NoMethodError when
|
|
525
|
+
# the draft loads.
|
|
511
526
|
def enum_for(model, name)
|
|
512
527
|
return nil unless model.respond_to?(:defined_enums)
|
|
513
528
|
|
|
@@ -515,7 +530,11 @@ module Permittable
|
|
|
515
530
|
return nil unless mapping
|
|
516
531
|
|
|
517
532
|
plural = name.pluralize
|
|
518
|
-
accessor = METHOD_NAME.match?(plural)
|
|
533
|
+
accessor = if METHOD_NAME.match?(plural) && model.respond_to?(plural)
|
|
534
|
+
"#{model.name}.#{plural}"
|
|
535
|
+
else
|
|
536
|
+
"#{model.name}.defined_enums[#{name.inspect}]"
|
|
537
|
+
end
|
|
519
538
|
{ mapping: mapping, accessor: accessor }
|
|
520
539
|
end
|
|
521
540
|
|
|
@@ -114,8 +114,16 @@ module Permittable
|
|
|
114
114
|
schema
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# deep_dup, not dup: .freeze is shallow, so the Array nested in an entry
|
|
118
|
+
# like :decimal ("type" => %w[string number]) stays live inside the frozen
|
|
119
|
+
# top-level Hash, and a shallow .dup would hand every :decimal field's
|
|
120
|
+
# exported schema that SAME Array. Nothing in this file mutates it in
|
|
121
|
+
# place (nullify! rebinds "type" to a new Array), but the exported
|
|
122
|
+
# document is caller-owned data, and a caller appending to it would
|
|
123
|
+
# otherwise silently rewrite the constant for every schema exported
|
|
124
|
+
# afterward in the process.
|
|
117
125
|
def scalar_schema(field)
|
|
118
|
-
schema = SCALAR_SCHEMAS.fetch(field[:type]).
|
|
126
|
+
schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup
|
|
119
127
|
apply_format_name!(schema, field)
|
|
120
128
|
apply_in!(schema, field)
|
|
121
129
|
apply_string_bounds!(schema, field)
|
|
@@ -151,7 +159,11 @@ module Permittable
|
|
|
151
159
|
min, max = length_bounds(field[:length])
|
|
152
160
|
schema["minItems"] = min if min
|
|
153
161
|
schema["maxItems"] = max if max
|
|
154
|
-
|
|
162
|
+
# deep_dup here for the same reason as scalar_schema: a scalar `of:`
|
|
163
|
+
# otherwise hands every array field the SAME nested Array/Hash from
|
|
164
|
+
# SCALAR_SCHEMAS.
|
|
165
|
+
schema["items"] =
|
|
166
|
+
field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).deep_dup
|
|
155
167
|
schema
|
|
156
168
|
end
|
|
157
169
|
|
|
@@ -343,10 +355,22 @@ module Permittable
|
|
|
343
355
|
# (silently changing a value outside any :decimal schema's justification)
|
|
344
356
|
# or coerced through Float, where a non-finite BigDecimal (Infinity, NaN)
|
|
345
357
|
# would crash JSON.generate.
|
|
358
|
+
#
|
|
359
|
+
# A `sensitive:` field's `default:`/`example:` are OMITTED rather than
|
|
360
|
+
# published: this exported document is the one channel meant to leave
|
|
361
|
+
# the app (client-generator tooling, a public docs endpoint), unlike the
|
|
362
|
+
# request log a `sensitive:` value is otherwise only redacted from, so
|
|
363
|
+
# shipping the real value here would defeat the redaction entirely.
|
|
364
|
+
# Omitting — rather than a placeholder string — matches how every other
|
|
365
|
+
# untranslatable or opaque fact in this file is handled: left out, with
|
|
366
|
+
# the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
|
|
367
|
+
# sensitive`) as the only signal that something is missing.
|
|
346
368
|
def annotate(schema, field)
|
|
347
369
|
decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
|
|
348
|
-
|
|
349
|
-
|
|
370
|
+
unless field[:sensitive]
|
|
371
|
+
schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
|
|
372
|
+
schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
|
|
373
|
+
end
|
|
350
374
|
schema["description"] = field[:desc] if field[:desc]
|
|
351
375
|
if field[:sensitive]
|
|
352
376
|
schema["writeOnly"] = true
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -115,8 +115,16 @@ module Permittable
|
|
|
115
115
|
Permittable.error_format == :problem
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
+
# ERROR_SCHEMA/PROBLEM_SCHEMA are frozen, but `.freeze` is shallow — only
|
|
119
|
+
# the top-level Hash is frozen, not the Hashes nested inside it — so
|
|
120
|
+
# handing either constant out by reference let a caller mutate a nested
|
|
121
|
+
# level of ITS document and permanently corrupt the shared constant for
|
|
122
|
+
# every document generated for the rest of the process. `deep_dup` (the
|
|
123
|
+
# same ActiveSupport helper the contract registry uses to copy authored
|
|
124
|
+
# default:/example: values before freezing, see permittable.rb) gives
|
|
125
|
+
# every caller its own independent copy instead.
|
|
118
126
|
def error_schema
|
|
119
|
-
problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA
|
|
127
|
+
(problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA).deep_dup
|
|
120
128
|
end
|
|
121
129
|
|
|
122
130
|
def error_media_type
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -403,7 +403,14 @@ module Permittable
|
|
|
403
403
|
# replay of #names — otherwise a sink deduplicating by value would hold
|
|
404
404
|
# both :ssn and "ssn".
|
|
405
405
|
name = name.to_s.downcase
|
|
406
|
-
|
|
406
|
+
unless name.empty?
|
|
407
|
+
# sensitive_parameter_sinks is the same process-global Array
|
|
408
|
+
# on_sensitive_parameter appends to under @registry_mutex. Reading it
|
|
409
|
+
# here without that lock is an unsynchronized concurrent mutation
|
|
410
|
+
# during iteration on any Ruby without a GVL — a thread class-loading
|
|
411
|
+
# a sensitive: true contract can race a thread installing a sink.
|
|
412
|
+
@registry_mutex.synchronize { sensitive_parameter_sinks.dup }.each { |sink| sink.call(name) }
|
|
413
|
+
end
|
|
407
414
|
nil
|
|
408
415
|
end
|
|
409
416
|
|
|
@@ -744,6 +751,17 @@ module Permittable
|
|
|
744
751
|
end
|
|
745
752
|
end
|
|
746
753
|
|
|
754
|
+
# Kernel#Integer/Float and BigDecimal() all accept underscore digit
|
|
755
|
+
# separators and surrounding whitespace — a convenience for a NUMBER
|
|
756
|
+
# LITERAL IN RUBY SOURCE, not for a request body. "1_8" is not how a
|
|
757
|
+
# client spells eighteen, and " 99 " is not how one spells ninety-nine;
|
|
758
|
+
# silently accepting either is the same kind of leniency as the
|
|
759
|
+
# NaN/Infinity/`0e10` cases below, just arriving from a different door.
|
|
760
|
+
# Checked against the raw String before any of those delegate, so a
|
|
761
|
+
# non-canonical spelling never reaches them at all.
|
|
762
|
+
INTEGER_FORMAT = /\A[+-]?\d+\z/
|
|
763
|
+
NUMERIC_FORMAT = /\A[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
764
|
+
|
|
747
765
|
def cast_integer(value)
|
|
748
766
|
case value
|
|
749
767
|
when Integer then [:ok, value]
|
|
@@ -751,7 +769,7 @@ module Permittable
|
|
|
751
769
|
# RangeError, which the ArgumentError rescue below does not catch), and
|
|
752
770
|
# no integer is what either one sent. Same rule as finite_float.
|
|
753
771
|
when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
|
|
754
|
-
when String then [:ok, Integer(value, 10)]
|
|
772
|
+
when String then value.match?(INTEGER_FORMAT) ? [:ok, Integer(value, 10)] : [:error, "invalid_type"]
|
|
755
773
|
else [:error, "invalid_type"]
|
|
756
774
|
end
|
|
757
775
|
rescue ArgumentError
|
|
@@ -761,7 +779,7 @@ module Permittable
|
|
|
761
779
|
def cast_float(value)
|
|
762
780
|
case value
|
|
763
781
|
when Numeric then finite_float(value.to_f)
|
|
764
|
-
when String then finite_float(Float(value), source: value)
|
|
782
|
+
when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
|
|
765
783
|
else [:error, "invalid_type"]
|
|
766
784
|
end
|
|
767
785
|
rescue ArgumentError
|
|
@@ -792,7 +810,8 @@ module Permittable
|
|
|
792
810
|
|
|
793
811
|
def cast_decimal(value)
|
|
794
812
|
case value
|
|
795
|
-
when Numeric
|
|
813
|
+
when Numeric then finite_decimal(BigDecimal(value.to_s))
|
|
814
|
+
when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
|
|
796
815
|
else [:error, "invalid_type"]
|
|
797
816
|
end
|
|
798
817
|
rescue ArgumentError
|
|
@@ -1561,8 +1580,10 @@ module Permittable
|
|
|
1561
1580
|
# gets); with one, the array exactly AS AUTHORED — the walker still runs,
|
|
1562
1581
|
# so a declaration mistake (an element `validate:` refuses, a sub-field
|
|
1563
1582
|
# default out of bounds) still fails at class load, but its cast result
|
|
1564
|
-
# is discarded rather than stored. `transform:`
|
|
1565
|
-
# never run on a default either way
|
|
1583
|
+
# is discarded rather than stored. The array's OWN `transform:` is
|
|
1584
|
+
# deliberately never run on a default either way; a SUB-FIELD's
|
|
1585
|
+
# `transform:` still runs during that walk, so "the walker's read" above
|
|
1586
|
+
# really is what an equivalent request produces — see AuthoredValues.
|
|
1566
1587
|
def validate_array_authored_value!(field, opt)
|
|
1567
1588
|
value = field[opt]
|
|
1568
1589
|
return if authored_nil!(field, opt)
|
|
@@ -1621,7 +1642,11 @@ module Permittable
|
|
|
1621
1642
|
def validate_message!(field)
|
|
1622
1643
|
spec = field[:message]
|
|
1623
1644
|
return if spec.nil?
|
|
1624
|
-
|
|
1645
|
+
|
|
1646
|
+
if spec.is_a?(String)
|
|
1647
|
+
field[:message] = freeze_authored(spec)
|
|
1648
|
+
return
|
|
1649
|
+
end
|
|
1625
1650
|
|
|
1626
1651
|
valid_hash = spec.is_a?(Hash) && !spec.empty? &&
|
|
1627
1652
|
spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
|
|
@@ -1630,7 +1655,7 @@ module Permittable
|
|
|
1630
1655
|
"or a Hash of violation code => String (e.g. { missing: \"is required\" })"
|
|
1631
1656
|
end
|
|
1632
1657
|
|
|
1633
|
-
field[:message] = spec.transform_keys(&:to_sym)
|
|
1658
|
+
field[:message] = freeze_authored(spec.transform_keys(&:to_sym))
|
|
1634
1659
|
end
|
|
1635
1660
|
end
|
|
1636
1661
|
|
|
@@ -2357,7 +2382,10 @@ module Permittable
|
|
|
2357
2382
|
|
|
2358
2383
|
declared = fields.map { |f| f[:name].to_s }
|
|
2359
2384
|
extra = hash.keys.map(&:to_s) - declared
|
|
2360
|
-
|
|
2385
|
+
if top_level
|
|
2386
|
+
extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
|
|
2387
|
+
extra -= [permittable_configured_csrf_key].compact
|
|
2388
|
+
end
|
|
2361
2389
|
return if extra.empty?
|
|
2362
2390
|
|
|
2363
2391
|
if unknown == :error
|
|
@@ -2382,6 +2410,25 @@ module Permittable
|
|
|
2382
2410
|
request.path_parameters.keys.map(&:to_s)
|
|
2383
2411
|
end
|
|
2384
2412
|
|
|
2413
|
+
# FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
|
|
2414
|
+
# name — but `config.action_controller.request_forgery_protection_token`
|
|
2415
|
+
# lets an app rename it, and an app that does trips `unknown: :error` on
|
|
2416
|
+
# every ordinary form submission: exactly the bug FORM_KEYS exists to
|
|
2417
|
+
# prevent, just spelled with the app's own key instead of the default one.
|
|
2418
|
+
# The configured name isn't knowable at class-load time (it can vary per
|
|
2419
|
+
# controller, and Rails may not have finished initializing yet), so it's
|
|
2420
|
+
# read fresh here off the live controller instead of folded into a frozen
|
|
2421
|
+
# constant. `request_forgery_protection_token` comes from
|
|
2422
|
+
# ActionController::RequestForgeryProtection, included by ActionController
|
|
2423
|
+
# ::Base; a plain params duck or a standalone Contract has no such method
|
|
2424
|
+
# and exempts nothing beyond FORM_KEYS's own "authenticity_token".
|
|
2425
|
+
def permittable_configured_csrf_key
|
|
2426
|
+
return nil unless respond_to?(:request_forgery_protection_token)
|
|
2427
|
+
|
|
2428
|
+
token = request_forgery_protection_token
|
|
2429
|
+
token && token.to_s
|
|
2430
|
+
end
|
|
2431
|
+
|
|
2385
2432
|
# A rootless contract's input without ParamsWrapper's copy of the body,
|
|
2386
2433
|
# when Rails made one (see process_action). Removed rather than merely
|
|
2387
2434
|
# exempted from the unknown-keys check, because the client never sent that
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: permittable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|