permittable 0.15.0 → 0.16.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 +36 -0
- data/README.md +17 -8
- data/lib/permittable/audit.rb +73 -22
- data/lib/permittable/error_envelope.rb +5 -0
- data/lib/permittable/generator.rb +39 -2
- data/lib/permittable/open_api.rb +45 -28
- data/lib/permittable/rspec.rb +4 -0
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +164 -14
- 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: '008b53fb20bca2df208a1bb8a09f942207790d4471b3e7346745df1806858184'
|
|
4
|
+
data.tar.gz: 2a3e3b24d9339ec2336f31690916bc1c25ef8206de97d94243d11497525d709b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 424948f6d0d1f8115985432a55b0d6e64b4955a4142d0f98b51407fe718bb642217ae3b89303e07d80dd56f475a404f3628bb62e5941a21601c7d6283d07d2da
|
|
7
|
+
data.tar.gz: 43aa1e3832649fc76fb382dfd0719ec9012ea4adf50e67224af9a939b6631ad7b2837e4c25c52a38b24d03973c40fa367f8dadf388ef5d7905b6f88da76e9ae4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.16.0 (2026-11-15)
|
|
4
|
+
<!-- title: date and boolean strings held to what the parser read, an audit that stops counting skipped contracts, and 422 rendered as an Integer -->
|
|
5
|
+
|
|
6
|
+
Four changes to what a request, an audit, an export or a spec is held to. A `:boolean` refuses a number that only equals 1 or 0, and a `:date` or `:datetime` String refuses any part the parser did not read (#181). `permittable:audit` reports an action whose `enforce_params_contract` callback was skipped as `no contract` (#184). The gem passes the Integer 422 or 400 to `render`, so Rails 7.1 with Rack 3.1 stops printing a deprecation warning (#183). `Permittable::OpenAPI.document` exports a literal `permit_params "*"` action, and `permittable:generate` drafts carry more TODO hints and can no longer break a comment across lines (#182).
|
|
7
|
+
|
|
8
|
+
Minor rather than patch: each entry can make a request, a boot, an audit gate or a spec that passes today fail. Before upgrading, read the entries that match what you use. A request can now be refused: a `:boolean` sent `1.0`, `0.0`, `BigDecimal("1")`, `Rational(1, 1)` or `Complex(1, 0)`, a `:date` or `:datetime` String with unread text (`"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`), and the localised or invisible-character forms that 0.15.1 read correctly (`"5. September 2026 um 10:00:00 MESZ"`, `"sam. 05 sept. 2026 10:00:00 CEST"`, `"2026-09-05"` with a left-to-right mark), all as `invalid_type` (#181). A boot can now fail: a `default:`, `example:` or `in:` on a `:boolean` field that holds such a number, and a `:date` or `:datetime` `example:` with unread text (#181). An audit gate can now fail: `permittable:audit[strict]` counts an `enforce: true` or monitor-mode action whose callback was skipped (#184). A spec can now fail: one that stubs `render` and expects `status: :unprocessable_entity` from the gem's own envelope now sees `422` (#183). No string that is still accepted is read as a different instant than on 0.15.1.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **A `:boolean` refuses a number that only equals 1 or 0.** `true`, `false`, `"true"`, `"false"`, `"1"`, `"0"`, `1` and `0` are accepted as before. `1.0`, `0.0`, `BigDecimal("1")`, `Rational(1, 1)` and `Complex(1, 0)` were accepted on 0.15.1, so a JSON `1.0` was read as `true`; they are now `invalid_type`. A `default:`, `example:` or `in:` on a `:boolean` field that holds one raises at class load (`:default for field :b violates its own contract (invalid_type)`); write it as `true`, `false`, `1` or `0`. (#181)
|
|
12
|
+
- **A `:date` or `:datetime` String with text the parser did not read is refused.** 0.15.1 accepted `"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`, `"2026-01-01 <script>"`, a NUL or zero-width character after the date, two conflicting offsets and `"2026-09-05 10:00 EST xyz"`; each is now `invalid_type`, and an authored `example:` in such a form raises at class load (`default:` and `in:` already did). Still read, as the same instant as 0.15.1: a browser `Date#toString` remark in brackets in any language (`"Sat Sep 05 2026 11:00:00 GMT+0300 (Москва, стандартное время)"`), a numeric offset followed by a zone abbreviation as a label, as Go's `time.String()` prints it (`"2026-09-05 10:00:00 +0800 CST"` is `02:00Z`), an RFC 9557 suffix (`"2026-09-05T10:00+01:00[Europe/Paris]"`), ActiveSupport `TimeWithZone#inspect` when the abbreviation and the offset agree, and one leading byte-order mark. Some other trailing junk is still read as on 0.15.1 (`"2026-09-05 22:00 am"`, a trailing `,` or `.`). (#181)
|
|
13
|
+
- **Known limit: strings 0.15.1 read correctly are now refused.** Localised joiner and weekday words (`"5. September 2026 um 10:00:00 MESZ"`, `"Sa 5. Sep 10:00:00 CEST 2026"`, `"sam. 05 sept. 2026 10:00:00 CEST"`), a left-to-right mark or other invisible character anywhere, and a byte-order mark anywhere but once at the start. Send ISO 8601. (#181)
|
|
14
|
+
- **A date String that 0.15.1 read as the wrong instant is refused.** A zone abbreviation followed by a different numeric offset (`"Sat, 05 Sep 2026 18:00:00 CST +08:00"` was `00:00Z` the next day), a spelled-out zone name with no brackets (`"... 10:00:00 AM Central European Summer Time"` was `16:00Z`) and `"EST5EDT"`. (#181)
|
|
15
|
+
- **Cost.** A plain ISO date or date-time costs the same as on 0.15.1. Other accepted shapes cost about 0.4 to 0.6 ms per value, three to four times 0.15.1, and a string with more than 16 parts is refused before any reparse. (#181)
|
|
16
|
+
- **`permittable:audit` no longer reports a skipped contract as covered.** An action whose `before_action` was removed with `skip_before_action :enforce_params_contract` (bare, `only:`, `except:`, `if:` or `unless:`; `ActionController::Base` and Metal; a subclass of a skipping parent) was covered in the audit, while a real request with an undeclared key got 200. An `enforce: true` or monitor-mode rule on such an action is now `no contract`, per action for `only:` and `except:`, with the note "request not checked before the action: skip_before_action :enforce_params_contract took its before_action off this action (checked only if the action reads permitted_params)", and `permittable:audit[strict]` counts it when the verb takes a body. A skip with `if:` or `unless:` is reported for every action it could reach, as "request may not be checked before the action: skip_before_action :enforce_params_contract is conditional (if:/unless:) and the audit cannot evaluate the condition (checked only if the action reads permitted_params)". This holds even when the action reads `permitted_params` and the request is validated there; to have such an action counted as covered, declare its rule lazily (neither `enforce: true` nor monitor mode, own or app-wide). Lazy rules are unchanged, and a `before_action :enforce_params_contract` declared again after the skip is covered again. The audit still cannot see a conditional skip made with `skip_callback`, a host's own `before_action :enforce_params_contract, only:` after a skip, or a `raise: false` skip on an ancestor that never included Permittable; see the README audit section. The matchers (`accept_params`, `reject_params`) do not see a skipped callback. (#184)
|
|
17
|
+
- **The status the gem renders is an Integer.** On Rails 7.1 with Rack 3.1 or later, every 422 the gem rendered printed `warning: Status code :unprocessable_entity is deprecated and will be removed in a future version of Rack. Please use :unprocessable_content instead.` The gem now hands `render`, and a bare Metal controller's `status=`, the Integer 422 or 400; the response status line is unchanged on every Rails. `InvalidParameters#status` and the `status:` a host's `render_error` receives stay Symbols, so a `render_error` that forwards that Symbol to `render` still gets the warning on Rails 7.1 and can pass the Integer itself. A status that is neither a Symbol, a String nor an Integer is passed through; in the problem+json format it raised `NoMethodError` and rendered a 500 on 0.15.1, and now renders. (#183)
|
|
18
|
+
- **`Permittable::OpenAPI.document` exports a literal `permit_params "*"` action.** It raised `NoMethodError` for a controller that declared one. (#182)
|
|
19
|
+
- **A generated `# TODO` comment can no longer carry Ruby.** A column name containing a line break ended the comment, and the rest of the note became a line of Ruby in the drafted contract; the json-array and binary-column TODOs of 0.15.1 had this. Every drafted line now has its line breaks collapsed to a space. (#182)
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **`permittable:generate` drafts carry new TODO hints** for a `:decimal` column's precision and scale (the contract does not bound precision), a column named like a Rails request key (`format`, `controller`, `action`, `authenticity_token`), and a json column that holds arrays. (#182)
|
|
23
|
+
- **README notes:** `permitted_params` is a `HashWithIndifferentAccess` in every mode; the cost of very many unknown keys under `unknown: :error`; a standalone `Contract` is fed the raw payload; a literal `"*"` action. (#182)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **`skip_before_action` is overridden on controllers that register the gem's callback**, to record a skip of `enforce_params_contract`; other skips are passed through untouched. `Permittable::Audit.inert?(controller, rule)` (undocumented) is gone. `Audit::Entry#inert` holds the reason (`:no_callbacks`, `:skipped` or `:conditional`) where it held `true`; `Entry#inert?` still returns true or false. (#184)
|
|
27
|
+
|
|
28
|
+
## 0.15.1 (2026-11-12)
|
|
29
|
+
<!-- title: a date String's hour and glued offset read or refused, and the exported body and descriptors list the routing keys, a renamed CSRF token and a route's format default -->
|
|
30
|
+
|
|
31
|
+
**Patch rather than minor.** Every string this release newly refuses was already misread by 0.15.0: each dropped an hour or an offset, or read midnight UTC by accident. The refused strings are `"2026-09-05 10 A.D."`, `"2026-09-05 10 a"`, `"2026-09-05 10 AD GMT+5"`, offsets that are not read whole or not honoured (`"EST+5"`, `"UTC+99"`, `"GMT+0560"`), and, for a `:date`, `"2026-09-05 GMT+5"`. The export changes are these: `rails_routes` descriptors now name a `format:` default; a contract field `format` leaves the body for such routes; a rootless multipart `unknown: :error` body gains a redundant `"format": {}`; and a rootless JSON `unknown: :error` body lists the routing keys and a renamed CSRF token as free properties.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **An hour before an era or a lone letter is refused on a date String.** `"2026-09-05 10 A.D."` and `"2026-09-05 10 a"` lost the hour on 0.15.0 and read as midnight; they are now refused as `invalid_type`, as `"2026-09-05 10 EST"` already was. (#179)
|
|
35
|
+
- **An offset glued to a zone name is read.** `"2026-09-05 GMT+5"` and `"2026-09-05 UTC+0530"` were dropped to midnight UTC on 0.15.0. A `:datetime` now reads them as midnight at that offset (`2026-09-04T19:00Z`, `2026-09-04T18:30Z`); a `:date` refuses them, since the instant is not midnight UTC. (#179)
|
|
36
|
+
- **The rootless `unknown: :error` JSON body lists the routing keys and a renamed CSRF token.** `controller`, `action`, `format` and the controller's `request_forgery_protection_token` are accepted by the runtime but failed the exported schema. They are now free, optional properties. (#180)
|
|
37
|
+
- **A route's `format:` default fills a contract field of that name.** The export left a field called `format` in the body and its `required` list, though the router fills it. `rails_routes` now names `format` in the descriptor's `defaults:`. (#180)
|
|
38
|
+
|
|
3
39
|
## 0.15.0 (2026-11-12)
|
|
4
40
|
<!-- title: zone names and hours that Date._parse drops honoured or refused, an export that matches what the server stores, an audit that stops reporting rules that cannot run, matchers that redact what they print, and a column guard that knows aliases and store accessors -->
|
|
5
41
|
|
data/README.md
CHANGED
|
@@ -536,7 +536,7 @@ request params
|
|
|
536
536
|
|
|
537
537
|
Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
|
|
538
538
|
|
|
539
|
-
In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
|
|
539
|
+
In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through. `permitted_params` is a `HashWithIndifferentAccess` in every mode, never an `ActionController::Parameters`, so `.permitted?` and `.require` are not available on it and `Model.create!(permitted_params)` does not raise `ForbiddenAttributesError`.
|
|
540
540
|
|
|
541
541
|
A bare `ActionController::Metal` controller has no callbacks and no `rescue_from` until it includes them. `include AbstractController::Callbacks` and then `include ActionController::Rescue` (the order `ActionController::Base` uses, so a rejection also covers the `before_action`), before or after `include Permittable`. The rendering modules (`AbstractController::Rendering`, `ActionController::Rendering`, `ActionController::Renderers::All`) are optional: without `ActionController::Rendering` the gem writes the error response itself, with the same status, content type and JSON body. An `enforce: true` rule, or a rule declaring `mode: :monitor`, raises `ArgumentError` at class load when `AbstractController::Callbacks` is missing. The class-load check reads only the rule's own `mode:`, not the app-wide `Permittable.mode`, which an initializer usually sets after the controller loads. A rule with no `mode:` under an app-wide `Permittable.mode = :monitor` therefore loads, but its `before_action` is not there to run it: `permittable:audit` reports those actions as `no contract` (`contract cannot run`) instead of as monitored. An `enforce: true` rule that is not in monitor mode still raises at class load when `ActionController::Rescue` is missing or was included before `AbstractController::Callbacks`. Both modules have to be in by the time of `permit_params`. A check registered at that first `permit_params` runs after any callback declared between the include and it (see [Per-request locales](#custom-error-messages-message)), and with `include Permittable` first, a `skip_before_action :enforce_params_contract` goes after the first `permit_params`, since the callback does not exist before it. On a bare `ActionController::Metal` controller that includes `ActionController::Rescue` after `include Permittable`, the gem registers its handler at the first `permit_params` instead, so there it is a catch-all declared after that `permit_params` that answers first, and a re-declaration goes after it.
|
|
542
542
|
|
|
@@ -570,6 +570,8 @@ class ReportsController < ApiController
|
|
|
570
570
|
end
|
|
571
571
|
```
|
|
572
572
|
|
|
573
|
+
A literal `"*"` in `permit_params` is only an action name: it matches an action called `"*"` and nothing else, and a rule with no actions is the catch-all. The `"*"` key is the OpenAPI export's catch-all marker for plain-Ruby hosts only.
|
|
574
|
+
|
|
573
575
|
Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
|
|
574
576
|
|
|
575
577
|
### The field DSL
|
|
@@ -605,7 +607,7 @@ optional :avatar, :file, validate: ->(file) { file.size <= 2.megabytes && file.c
|
|
|
605
607
|
array :attachments, of: :file, length: 0..5
|
|
606
608
|
```
|
|
607
609
|
|
|
608
|
-
`:file` takes `required:`, `nullable:`, `validate:`, `transform:`, `desc:`, `sensitive:`, `virtual:` and `message:`. `in:`, `length:`, `format:`, `normalize:`, `default:` and `example:` are refused at class load. A form's empty file input sends `""`, which is absent as everywhere else. The [drift guard](#the-schema-drift-guard) does not look for a column behind a `:file` field (an attachment is not a column), and the OpenAPI export documents an operation with a file as `multipart/form-data`, its properties named the way Rails reads the parts (`user[avatar]`, `docs[]`, `user[address][scan]`). An array of hashes is sent with an index (`items[0][photo]`, `items[0][name]`, `items[1][photo]`, …), as Rails' `fields_for` renders it, and the document describes those leaves with anchored `patternProperties`. What a flat body cannot carry, a container's own `required`, `minItems`/`maxItems`, `nullable`, `description` and custom-validation marker, is kept in the body's `x-permittable-form-groups`, keyed by the container's bracket name (`user[items]`), with `[]` standing for the index of a container inside an element (`user[items][][info]`). Send a nullable container's null as an empty part named by that bracket name, without `[]` (`user[docs]=""`; `user[docs][]=""` is an empty array). Under `unknown: :error` the multipart body sets `additionalProperties: false`, which refuses an undeclared part at any depth (`user[address][bogus]`, `user[items][0][bogus]`), and it lists the parts the server still takes: a nullable container's null as `{"const": ""}`, a `:json` field's keys (`user[extra][any]`) and, for a `root:` rule, any part outside the root, or for a rootless one, the
|
|
610
|
+
`:file` takes `required:`, `nullable:`, `validate:`, `transform:`, `desc:`, `sensitive:`, `virtual:` and `message:`. `in:`, `length:`, `format:`, `normalize:`, `default:` and `example:` are refused at class load. A form's empty file input sends `""`, which is absent as everywhere else. The [drift guard](#the-schema-drift-guard) does not look for a column behind a `:file` field (an attachment is not a column), and the OpenAPI export documents an operation with a file as `multipart/form-data`, its properties named the way Rails reads the parts (`user[avatar]`, `docs[]`, `user[address][scan]`). An array of hashes is sent with an index (`items[0][photo]`, `items[0][name]`, `items[1][photo]`, …), as Rails' `fields_for` renders it, and the document describes those leaves with anchored `patternProperties`. What a flat body cannot carry, a container's own `required`, `minItems`/`maxItems`, `nullable`, `description` and custom-validation marker, is kept in the body's `x-permittable-form-groups`, keyed by the container's bracket name (`user[items]`), with `[]` standing for the index of a container inside an element (`user[items][][info]`). Send a nullable container's null as an empty part named by that bracket name, without `[]` (`user[docs]=""`; `user[docs][]=""` is an empty array). Under `unknown: :error` the multipart body sets `additionalProperties: false`, which refuses an undeclared part at any depth (`user[address][bogus]`, `user[items][0][bogus]`), and it lists the parts the server still takes: a nullable container's null as `{"const": ""}`, a `:json` field's keys (`user[extra][any]`) and, for a `root:` rule, any part outside the root, or for a rootless one, the keys the runtime exempts: Rails' routing keys (`controller`, `action`, `format`), the form keys (`authenticity_token`, `_method`, `utf8`, `commit`, `button`) and the CSRF token name the controller renamed with `request_forgery_protection_token`. An image submit's `x`/`y` and a per-instance override of the token cannot be known when the document is built, so they are not listed. The children of an optional hash are required only when the hash is sent (the group entry says the hash is optional), and `patternProperties` cannot mark a leaf of an array element required.
|
|
609
611
|
|
|
610
612
|
An ActiveStorage direct upload (`form.file_field :avatar, direct_upload: true`) sends the file from the browser straight to the storage service, and the form then submits only the blob's signed id, a String, which a `:file` field refuses as `invalid_type`. Declare such a field `:string` and attach the signed id (`record.avatar.attach(signed_id)`):
|
|
611
613
|
|
|
@@ -694,13 +696,13 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
694
696
|
| `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
|
|
695
697
|
| `: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 |
|
|
696
698
|
| `: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 |
|
|
697
|
-
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
699
|
+
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, the Integers `1`/`0` | `"yes"`, `"on"`, `2`, and a number that merely equals 1 or 0 (`1.0`, which is how JSON can send it) |
|
|
698
700
|
| `:date` | `Date`; a `Time`, `DateTime` or `ActiveSupport::TimeWithZone` that is exactly midnight UTC, as that `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`); a string with a time or zone only if it is exactly midnight UTC (`"2026-09-05T00:00:00Z"`) | Unparseable strings, **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`), and any other instant, as an object or a string — one with a time of day, or a midnight in another zone |
|
|
699
701
|
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time (an ISO date with an offset or `Z` and no time, `"2026-09-05-05:00"`, is midnight at that offset) | Unparseable strings, any string without a complete date (`"10:30"`), any other date followed by a bare offset and no time of day (`"Sep 5 2026 -05:00"`), an offset or an hour the parser would drop (`"12:00 2026-09-05 -05:00"`, `"2026-09-05 10 -05:00"`, `"2026-09-05T10"`), any word after a date that is not a zone Ruby knows, a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`), and an ISO date followed by `T` and no time (`"2026-09-05T"`) |
|
|
700
702
|
| `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
|
|
701
703
|
| `:file` | An upload object (`ActionDispatch::Http::UploadedFile`, `Rack::Test::UploadedFile`), passed through uncast | A `String` (including a form's file field sent as text), numbers, hashes, arrays |
|
|
702
704
|
|
|
703
|
-
**Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time. A zone *name* after a date is honoured where Ruby knows it, with or without a time (`"2026-09-05 EST"` is `2026-09-05T05:00:00Z`, so a `:date` refuses it, and `"Sat Sep 5 10:00:00 2026 EST"` is `15:00:00Z`), and refused where it does not. So is **any other word** after a complete date that is not a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`, `"2026-09-05 foo"`): all it can be is a zone, and a zone Ruby cannot honour is never guessed at. An offset after the year of git's and ctime's format is read (`"Sat Sep 5 10:00:00 2026 -0500"` is `15:00:00Z`). An hour or offset the parser would drop or misread (`"2026-09-05T10"`, `"2026-09-05 10 -05:00"`, `"12:00 2026-09-05 -05:00"`) is refused rather than read as some other instant.
|
|
705
|
+
**Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time. A zone *name* after a date is honoured where Ruby knows it, with or without a time (`"2026-09-05 EST"` is `2026-09-05T05:00:00Z`, so a `:date` refuses it, and `"Sat Sep 5 10:00:00 2026 EST"` is `15:00:00Z`), and refused where it does not. So is **any other word** after a complete date that is not a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`, `"2026-09-05 foo"`): all it can be is a zone, and a zone Ruby cannot honour is never guessed at. An offset glued to a zone name with no time is midnight at that offset where Ruby honours it (`"2026-09-05 GMT+5"` is `2026-09-04T19:00:00Z`) and refused where it does not (`"2026-09-05 UTC+99"`, `"2026-09-05 EST+5"`). An offset after the year of git's and ctime's format is read (`"Sat Sep 5 10:00:00 2026 -0500"` is `15:00:00Z`). An hour or offset the parser would drop or misread (`"2026-09-05T10"`, `"2026-09-05 10 -05:00"`, `"2026-09-05 10 A.D."`, `"2026-09-05 10 a"`, `"12:00 2026-09-05 -05:00"`) is refused rather than read as some other instant. Likewise any part of the string the parser read nothing from is refused, not dropped: junk after, before or between dates (`"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`, `"2026-01-01 <script>"`), and control or zero-width characters (bar one leading byte-order mark, which a CSV or text file saved as UTF-8 starts with, and which is dropped). What is let through repeats something that was read: a remark in brackets (`"(Central Daylight Time)"`, `"(Москва, стандартное время)"`, in any language, with commas, `&` or a nested bracket but no digits), an RFC 9557 suffix (`"[Europe/London]"`, `"[GMT+01:00]"`, the offset governs), Go's `m=+0.1`, and an offset or zone name that agrees with the one read (`"EDT -04:00"`), and a zone abbreviation after a numeric offset, which only labels it (`"2026-09-05 10:00:00 +0800 CST"` is `02:00:00Z`, as Go and `date` print it). Two zones in the other order (`"EDT -0700"`, `"CST +08:00"`: the abbreviation is what is read) or two different offsets are refused.
|
|
704
706
|
|
|
705
707
|
**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.
|
|
706
708
|
|
|
@@ -810,7 +812,7 @@ rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters
|
|
|
810
812
|
|
|
811
813
|
Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
|
|
812
814
|
|
|
813
|
-
**`details` is complete; `message` is prose.** The `details` array names **every** offender, however many there are — it is the machine-readable channel and nothing is dropped from it. The `message` string is a sentence for a person, and it also lands in your logs and in every exception tracker, so it is bounded: at most ten offenders, each truncated past 120 characters, then a count of the rest (`…, and 49990 more`). Before that bound, a request carrying 50,000 undeclared keys against `unknown: :error` produced a **1 MB** exception message and a 1 MB log line. The 422 body still carries the complete `details`, so it stays proportional to the number of violations; the field bounds are what keep that number down.
|
|
815
|
+
**`details` is complete; `message` is prose.** The `details` array names **every** offender, however many there are — it is the machine-readable channel and nothing is dropped from it. The `message` string is a sentence for a person, and it also lands in your logs and in every exception tracker, so it is bounded: at most ten offenders, each truncated past 120 characters, then a count of the rest (`…, and 49990 more`). Before that bound, a request carrying 50,000 undeclared keys against `unknown: :error` produced a **1 MB** exception message and a 1 MB log line. The 422 body still carries the complete `details`, so it stays proportional to the number of violations; the field bounds are what keep that number down. The check itself lists one violation per undeclared key, so its cost grows with the key count (tens of seconds at a few hundred thousand keys, growing linearly); cap the request body size or key count in front of the app.
|
|
814
816
|
|
|
815
817
|
**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.
|
|
816
818
|
|
|
@@ -928,7 +930,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
|
|
|
928
930
|
|
|
929
931
|
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.
|
|
930
932
|
|
|
931
|
-
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it, and the export then documents that field as the path parameter's schema (`required :id, :integer, in: 1..1000` becomes `{"type":"integer","minimum":1,"maximum":1000}`) and leaves it out of the request body, `required` list included, so a body without `id` is valid. Only a rootless contract does this, and only for a name the route templates; a `root:` contract's `id` is a body field and stays one, and so does an `array`, nested or `:json` field, since no URL segment can be one. Under `unknown: :error` the body schema forbids undeclared keys, but the
|
|
933
|
+
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it, and the export then documents that field as the path parameter's schema (`required :id, :integer, in: 1..1000` becomes `{"type":"integer","minimum":1,"maximum":1000}`) and leaves it out of the request body, `required` list included, so a body without `id` is valid. Only a rootless contract does this, and only for a name the route templates; a `root:` contract's `id` is a body field and stays one, and so does an `array`, nested or `:json` field, since no URL segment can be one. Under `unknown: :error` the body schema forbids undeclared keys, but the keys the runtime exempts (`controller`, `action`, `format`, `authenticity_token`, `_method`, `utf8`, `commit`, `button`, and the controller's renamed CSRF token, if any) stay in a rootless contract's JSON body as free, optional properties, as in its multipart body, because the runtime accepts them (a `root:` contract's body is unchanged: they sit outside the root; a name the contract declares keeps its own schema; `Contract#json_schema` describes the contract alone and lists none), and every name the route templates stays in it as a property that takes any value and is never required, declared or not: `PATCH /users/1` with `{"id":1,"name":"x"}` is accepted, because the path parameter is exempt and the URL's `id` overwrites the body's. A name the route fills from `defaults:` (`post "w", to: "widgets#create", defaults: { kind: "x" }`, on the route, its `scope` or the `mount` it is reached through) is no body field either, since the router overwrites the body's value: the export leaves it out of the body, `required` list included, and under `unknown: :error` keeps it as that same free property, but declares no path parameter for it, because no URL carries it. `rails_routes` names such keys in each descriptor's `defaults:`, apart from `controller` and `action`; a `format:` default (`defaults: { format: "json" }`) is named like any other, so a contract field called `format` leaves the body and its `required` list for such a route. **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.
|
|
932
934
|
|
|
933
935
|
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.
|
|
934
936
|
|
|
@@ -1303,6 +1305,8 @@ Three things it tells you that nothing else does:
|
|
|
1303
1305
|
- **Which contracts aren't enforcing yet.** The audit runs inside the app, so unlike the exported OpenAPI it resolves the **effective** mode — a rule's own `mode:` first, then your app-wide `Permittable.mode`. This is the [monitor-mode](#monitor-mode-roll-out-without-rejecting) rollout dashboard.
|
|
1304
1306
|
- **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind. A contract on a shared base controller is stale only when no controller that inherits it reaches the action, and it is listed once, under the class that declares it.
|
|
1305
1307
|
|
|
1308
|
+
A contract that validates from the `before_action` (an `enforce: true` rule, or one in monitor mode) but whose callback was removed with a plain `skip_before_action :enforce_params_contract` reads `no contract — request not checked before the action: ...`, and counts against `[strict]` if it takes a body. `enforce: true` promises the check runs before the action whatever the action does, and the skip took that away. The note says the request is checked only if the action reads `permitted_params`: one that does is still validated there (rejected, or a monitor event), so the row can be a gap that is not one in practice. The audit does not read the action's code. To have such an action count as covered, declare its rule as a lazy one, neither `enforce: true` nor monitor mode (its own `mode:` or an app-wide `Permittable.mode = :monitor`, which makes every rule a monitor one): it validates when the action reads `permitted_params`, skip or not, and the audit reads it as covered. A subclass that skips what its parent declared is reported the same way, and so is a child's own `permit_params` under a parent that skipped it, since the callback is registered once per class tree and is not put back. A skip with `only:` or `except:` is read per action, the way Rails compares them (Symbols or Strings, one name or an Array): the actions it names read as above, the ones it spares stay covered. Skips in a row add up, and a subclass's skip never reaches its parent. A skip with `if:` or `unless:` cannot be evaluated without running your code, so every action it could reach reads `no contract — request may not be checked before the action: ... is conditional`, whether or not the condition holds at request time; an `only:` skip that also has `if:` is reported as skipped for the actions it names. Declaring `before_action :enforce_params_contract` again after a skip puts the check back, and the audit reads it as covered. The audit learns of a skip from the `skip_before_action` call itself (the gem wraps it on the controller once it registers its callback), so three things stay invisible: a skip made with `raise: false` on an ancestor that never included `Permittable` (it has nothing to skip itself, Rails rewrites the callback of the subclass that registered it, and nothing is recorded), a conditional skip made with `skip_callback(:process_action, :before, :enforce_params_contract, if: ...)` directly, and a `before_action :enforce_params_contract, only: ...` the app declares itself. All leave the action reported as covered; a skip made with `skip_callback` and no condition removes the callback and is reported. Check those with a request spec.
|
|
1309
|
+
|
|
1306
1310
|
A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. Rails sends a request to the first route that matches its path and verb, and the audit lists what Rails dispatches. With `get "hooks", to: "webhooks#index"` before `match "hooks", to: "webhooks#receive", via: :all`, `GET /hooks` is listed under `index` only, and `receive` gets the other four verbs. Turn the two around and the `get` route is never reached, so it is not listed, and a contract on `index` shows as stale. The same rule removes a route declared twice, and the OpenAPI export applies it too. Only an unconstrained route hides the routes behind it, and only on the identical path. A route with a constraint can pass a request on: a segment requirement (`id: /\d+/`), a subdomain or host, or a constraint object or lambda. So the routes after it stay listed, even when the constraint happens to admit every request. With `constraints(AdminConstraint) { post "settings", ... }` ahead of `post "settings", to: "settings#update"`, every non-admin `POST` reaches `settings#update`, so its row stays and `[strict]` still counts it. The audit errs towards an extra row rather than a hidden route. Paths that differ at all, even only in a variable's name (`users/:id` and `users/:slug`), are both listed. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
|
|
1307
1311
|
|
|
1308
1312
|
A catch-all 404 route (`match "*path", to: "application#not_found", via: :all`) shows its POST, PUT and PATCH rows as accepting a body. They do accept one: every stray body reaches the controller. Route only GET to the controller (Rails answers HEAD from it). Send the other verbs to a plain Rack endpoint, which never parses the body and which the audit does not list:
|
|
@@ -1430,7 +1434,7 @@ Both work on a controller class, a controller instance, or a [standalone `Contra
|
|
|
1430
1434
|
|
|
1431
1435
|
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.
|
|
1432
1436
|
|
|
1433
|
-
The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off), and a Symbol value (`status: :active`) reads as the String a request carries; a standalone `Contract` gets the payload as given,
|
|
1437
|
+
The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off), and a Symbol value (`status: :active`) reads as the String a request carries; a standalone `Contract` gets the raw payload as given, with no deep munge and no Symbol-to-String conversion of values (a Symbol value there is still `invalid_type`), because `Contract#call` is fed by app code, not by a request. A failure message that echoes params (the returned hash, a `returning` expectation) redacts them like a request log: the names of `sensitive:` fields, plus the app's `config.filter_parameters` when a Rails application is booted. Matching is by substring, like Rails, so `sensitive: true` on `pin` also redacts `shipping`; when a `returning` mismatch is hidden by that, the message adds `differs at: pin, shipping`, names only. Under a `root:` contract the hash is filtered as the request logs it, under its root, so a scoped filter such as `"payment.number"` applies; the note stops at a redacted key and never names anything below it. They do not see a skipped `enforce_params_contract` callback either: `reject_params` still passes for an action whose real request is not checked before the action runs, because its callback was removed with `skip_before_action`. `permittable:audit` is what reports that.
|
|
1434
1438
|
|
|
1435
1439
|
### Standalone contracts (no controller)
|
|
1436
1440
|
|
|
@@ -1487,7 +1491,7 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
|
1487
1491
|
|
|
1488
1492
|
Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
|
|
1489
1493
|
|
|
1490
|
-
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`, and a rule's `finalize` block `x-permittable-finalize` (on the rule's own object, inside the `root:` wrapper when there is one, and on the operation too, so the marker survives an operation that has no request body); actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string` unless the contract says more, because the route set doesn't say what an `:id` is and the exporter won't invent it: a rootless contract's *scalar* field of the same name is that parameter's schema (see [Unknown parameters](#unknown-parameters) for what that does to the request body), minus any `default:` and `nullable:`, since the URL always supplies a non-null value. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
1494
|
+
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` and `"12345\n"` at runtime (the trailing newline too, not only in the export), and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`, and a rule's `finalize` block `x-permittable-finalize` (on the rule's own object, inside the `root:` wrapper when there is one, and on the operation too, so the marker survives an operation that has no request body); actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string` unless the contract says more, because the route set doesn't say what an `:id` is and the exporter won't invent it: a rootless contract's *scalar* field of the same name is that parameter's schema (see [Unknown parameters](#unknown-parameters) for what that does to the request body), minus any `default:` and `nullable:`, since the URL always supplies a non-null value. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
1491
1495
|
|
|
1492
1496
|
**Mounted engines are documented under their mount point.** The task follows each `mount Billing::Engine => "/billing"` into the engine's routes, so an engine's operations land in `paths` as `/billing/...` (a dynamic mount point becomes a `{tenant}` template). A Rack mount that is not an engine is skipped. See [Auditing coverage](#auditing-coverage-permittableaudit) for the details and the one limit on shared parameter names.
|
|
1493
1497
|
|
|
@@ -1521,6 +1525,11 @@ Where they legitimately differ, the spec names the reason and asserts the **dire
|
|
|
1521
1525
|
- **`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.
|
|
1522
1526
|
- **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.
|
|
1523
1527
|
|
|
1528
|
+
Two more go the same way but sit outside that spec's JSON-only harness (it has no multipart or Rails-parsing step), so they are documented here and not asserted there:
|
|
1529
|
+
|
|
1530
|
+
- **Rails' `rows[][name]` naming.** The server accepts Rails' unindexed form naming in a multipart body; the export describes only the indexed `rows[0][name]` form.
|
|
1531
|
+
- **A dropped array element.** Rails' parameter parsing removes a `null` element before the contract runs, and the contract drops a blank one from a non-string array (`""` in an `:integer` array), while the schema's `items` refuses both. A standalone `Contract` still refuses `["a", null]`.
|
|
1532
|
+
|
|
1524
1533
|
Seven cases go the other way, and are worth knowing before you hand the document to a client. Each rule stays visible on its own field, and the spec asserts that as well as the direction:
|
|
1525
1534
|
|
|
1526
1535
|
- **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.
|
data/lib/permittable/audit.rb
CHANGED
|
@@ -43,14 +43,24 @@ module Permittable
|
|
|
43
43
|
outside_root: "Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):"
|
|
44
44
|
}.freeze
|
|
45
45
|
|
|
46
|
+
# What an entry's `inert` reason reads as in the table.
|
|
47
|
+
INERT_NOTES = {
|
|
48
|
+
no_callbacks: "contract cannot run: controller lacks AbstractController::Callbacks (no before_action)",
|
|
49
|
+
skipped: "request not checked before the action: skip_before_action :enforce_params_contract took its " \
|
|
50
|
+
"before_action off this action (checked only if the action reads permitted_params)",
|
|
51
|
+
conditional: "request may not be checked before the action: skip_before_action :enforce_params_contract is " \
|
|
52
|
+
"conditional (if:/unless:) and the audit cannot evaluate the condition " \
|
|
53
|
+
"(checked only if the action reads permitted_params)"
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
46
56
|
# One routed action, paired with the rule a request for it would resolve
|
|
47
57
|
# through (nil when nothing covers it — including when the controller
|
|
48
58
|
# never included the concern, which is exactly the case worth finding).
|
|
49
59
|
#
|
|
50
60
|
# `missing_action` marks a route to an action the controller does not
|
|
51
61
|
# define — see Audit.missing_action?. `inert` marks one whose rule exists
|
|
52
|
-
# but cannot run, so `rule` is nil and the action is uncovered
|
|
53
|
-
# Audit.
|
|
62
|
+
# but cannot run, so `rule` is nil and the action is uncovered: the reason,
|
|
63
|
+
# :no_callbacks, :skipped or :conditional — see Audit.inert_reason. `route` identifies the route the
|
|
54
64
|
# path came from (nil for a hand-built descriptor, which is then its own
|
|
55
65
|
# route); see `summary`. `ignored` is why the entry is left out of the
|
|
56
66
|
# counts — a key of IGNORE_REASONS — or nil when it is audited.
|
|
@@ -69,7 +79,7 @@ module Permittable
|
|
|
69
79
|
end
|
|
70
80
|
|
|
71
81
|
def inert?
|
|
72
|
-
inert
|
|
82
|
+
!inert.nil?
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
# The mode this action would actually run in, rule-level declaration
|
|
@@ -118,32 +128,74 @@ module Permittable
|
|
|
118
128
|
routes.select { |route| route[:controller].to_s == key }.map do |route|
|
|
119
129
|
action = route[:action].to_s
|
|
120
130
|
rule = rule_for(controller, action)
|
|
121
|
-
inert =
|
|
131
|
+
inert = inert_reason(controller, rule, action)
|
|
122
132
|
Entry.new(controller: key, action: action, verb: route[:verb], path: route[:path],
|
|
123
133
|
rule: (rule unless inert), missing_action: missing[action], route: route[:route],
|
|
124
134
|
ignored: configured_ignore(key, action, ignore) || outside, inert: inert)
|
|
125
135
|
end
|
|
126
136
|
end
|
|
127
137
|
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
# Why `rule` is declared on `controller` but may not run for `action`, or
|
|
139
|
+
# nil when it does: the gem's before_action is not in the controller's
|
|
140
|
+
# callback chain, or is skipped for that action, while the rule validates
|
|
141
|
+
# from that callback (an `enforce: true` rule, or one in monitor mode by
|
|
142
|
+
# its own mode: or the app-wide Permittable.mode). One of
|
|
143
|
+
# - :no_callbacks: a bare ActionController::Metal controller without
|
|
144
|
+
# AbstractController::Callbacks at its `permit_params`, where the gem
|
|
145
|
+
# never registered it (a callbacks module included afterwards does not
|
|
146
|
+
# undo that);
|
|
147
|
+
# - :skipped: `skip_before_action :enforce_params_contract` took it out of
|
|
148
|
+
# this class's chain and its subclasses' (permit_params does not register
|
|
149
|
+
# it a second time), or its only:/except: name this action;
|
|
150
|
+
# - :conditional: a skip with if:/unless: that could reach this action,
|
|
151
|
+
# whose condition the audit does not evaluate, since that is host code.
|
|
152
|
+
# permit_params raises for the first at class load, but reads only the
|
|
153
|
+
# rule's own mode, since the app-wide one is often set after the class
|
|
154
|
+
# loads; the effective mode is judged here. Such an action is audited as
|
|
155
|
+
# uncovered rather than as the monitored or enforced one it claims to be.
|
|
156
|
+
#
|
|
157
|
+
# The conditions of a skip are read from the record Permittable::SkipRecorder
|
|
158
|
+
# keeps of the `skip_before_action` call, and apply only while the callback
|
|
159
|
+
# in the chain is the one that call left. A skip made with `skip_callback`
|
|
160
|
+
# directly, or a `before_action :enforce_params_contract` of the host's own
|
|
161
|
+
# with conditions, is not seen unless it removed the callback outright.
|
|
162
|
+
def inert_reason(controller, rule, action)
|
|
163
|
+
return unless rule && (rule[:enforce] || (rule[:mode] || Permittable.mode) == :monitor)
|
|
164
|
+
return :no_callbacks if never_registered?(controller)
|
|
165
|
+
return unless controller.respond_to?(:_process_action_callbacks)
|
|
166
|
+
|
|
167
|
+
callback = controller._process_action_callbacks.find { |c| c.kind == :before && c.filter == :enforce_params_contract }
|
|
168
|
+
callback ? skip_reason(controller, callback, action) : :skipped
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def skip_reason(controller, callback, action)
|
|
172
|
+
held = controller.permittable_skips if controller.respond_to?(:permittable_skips)
|
|
173
|
+
return unless held && held[:callback].equal?(callback)
|
|
174
|
+
|
|
175
|
+
effects = held[:skips].map { |skip| skip_effect(skip, action) }
|
|
176
|
+
effects.include?(:skipped) ? :skipped : effects.compact.first
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# What one skip does to `action`, as Rails composes it: each skip adds its
|
|
180
|
+
# only: and except: to the callback as conditions that must all hold, so
|
|
181
|
+
# the action is skipped when it is in only: or outside except:. An if: or
|
|
182
|
+
# unless: makes it :conditional wherever only:/except: do not skip it.
|
|
183
|
+
def skip_effect(skip, action)
|
|
184
|
+
named = skip[:only]&.include?(action) || (skip[:except] && !skip[:except].include?(action))
|
|
185
|
+
return :skipped if named || (skip[:only].nil? && skip[:except].nil? && !skip[:conditional])
|
|
186
|
+
|
|
187
|
+
:conditional if skip[:conditional]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def never_registered?(controller)
|
|
191
|
+
defined?(::ActionController::Metal) && controller.is_a?(Class) && controller < ::ActionController::Metal &&
|
|
144
192
|
!(controller.respond_to?(:permittable_callback_registered) && controller.permittable_callback_registered)
|
|
145
193
|
end
|
|
146
194
|
|
|
195
|
+
# module_function made public singleton copies of these; they are this
|
|
196
|
+
# module's own steps, not API.
|
|
197
|
+
private_class_method :inert_reason, :skip_reason, :skip_effect, :never_registered?
|
|
198
|
+
|
|
147
199
|
# Why `controller` — or one of its actions — is left out of the audit and
|
|
148
200
|
# of permittable:generate: :configured (Permittable.audit_ignore names the
|
|
149
201
|
# controller, or "controller#action"), :outside_root, or nil. The
|
|
@@ -490,8 +542,7 @@ module Permittable
|
|
|
490
542
|
def inert_note(entry)
|
|
491
543
|
return unless entry.inert?
|
|
492
544
|
|
|
493
|
-
["
|
|
494
|
-
("ACCEPTS A BODY" if entry.body?)].compact.join(" — ")
|
|
545
|
+
[INERT_NOTES.fetch(entry.inert), ("ACCEPTS A BODY" if entry.body?)].compact.join(" — ")
|
|
495
546
|
end
|
|
496
547
|
|
|
497
548
|
# The one wording for a route Rails would 404, covered or not.
|
|
@@ -72,6 +72,9 @@ module Permittable
|
|
|
72
72
|
# response_body=. `render json:` calls `to_json` on a Hash, so the bytes
|
|
73
73
|
# are the same. A controller with `render` always takes the first path.
|
|
74
74
|
def render_json(controller, payload, status:, content_type: nil)
|
|
75
|
+
# The number, not the symbol: Rack >= 3.1 deprecates :unprocessable_entity
|
|
76
|
+
# (:unprocessable_content there) and Rails 7.1 passes it to Rack as is.
|
|
77
|
+
status = status_code(status) || status
|
|
75
78
|
if !controller.respond_to?(:render) && controller.respond_to?(:response_body=)
|
|
76
79
|
controller.status = status
|
|
77
80
|
controller.content_type = content_type if content_type
|
|
@@ -192,6 +195,8 @@ module Permittable
|
|
|
192
195
|
def status_code(status)
|
|
193
196
|
return status if status.is_a?(Integer)
|
|
194
197
|
|
|
198
|
+
return nil unless status.respond_to?(:to_sym)
|
|
199
|
+
|
|
195
200
|
symbol = status.to_sym
|
|
196
201
|
STATUS_CODES[symbol] ||
|
|
197
202
|
(defined?(Rack::Utils) && Rack::Utils::SYMBOL_TO_STATUS_CODE[symbol])
|
|
@@ -43,7 +43,8 @@ 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, :array, :array_default,
|
|
46
|
+
:unread, :rule, :listed, :assigned, :array, :array_default, :precision, :scale,
|
|
47
|
+
keyword_init: true) do
|
|
47
48
|
def in_rule(rule, listed: false)
|
|
48
49
|
self.class.new(**to_h, rule: rule, listed: listed)
|
|
49
50
|
end
|
|
@@ -556,6 +557,8 @@ module Permittable
|
|
|
556
557
|
default_function: column.respond_to?(:default_function) && column.default_function,
|
|
557
558
|
array: column.respond_to?(:array?) && column.array? == true,
|
|
558
559
|
array_default: array_default?(column.default),
|
|
560
|
+
precision: column.respond_to?(:precision) && column.precision,
|
|
561
|
+
scale: column.respond_to?(:scale) && column.scale,
|
|
559
562
|
sensitive: sensitive_role(model, column.name), assigned: assigned?(model, column),
|
|
560
563
|
**model_facts(model, column))
|
|
561
564
|
end
|
|
@@ -1159,9 +1162,30 @@ module Permittable
|
|
|
1159
1162
|
todos << "TODO: #{column.name} #{SCANNED_SENSITIVE.fetch(column.sensitive)}" if column.sensitive
|
|
1160
1163
|
todos << enum_todo(column) if column.enum_integers
|
|
1161
1164
|
todos << unread_todo(column) if column.unread
|
|
1165
|
+
todos.concat(hint_todos(column))
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# Hints about what a contract cannot say for a column, so a reviewer
|
|
1169
|
+
# sees them before the draft is enforced. Comments only.
|
|
1170
|
+
def hint_todos(column)
|
|
1171
|
+
todos = []
|
|
1172
|
+
if column.type == :decimal && column.precision
|
|
1173
|
+
todos << "TODO: decimal(#{column.precision},#{column.scale || 0}): the contract does not bound precision; " \
|
|
1174
|
+
"a Postgres app may 500 on overflow"
|
|
1175
|
+
end
|
|
1176
|
+
if RAILS_REQUEST_KEYS.include?(column.name.to_s)
|
|
1177
|
+
todos << "TODO: #{column.name} is also a Rails request key — if this contract is rootless (root: false) " \
|
|
1178
|
+
"the router's value collides with it"
|
|
1179
|
+
end
|
|
1180
|
+
if JSON_TYPES.include?(column.type)
|
|
1181
|
+
todos << "TODO: #{column.name} (#{column.type}): a :json field accepts an object only, not an array"
|
|
1182
|
+
end
|
|
1162
1183
|
todos
|
|
1163
1184
|
end
|
|
1164
1185
|
|
|
1186
|
+
RAILS_REQUEST_KEYS = %w[controller action format authenticity_token].freeze
|
|
1187
|
+
JSON_TYPES = %i[json jsonb].freeze
|
|
1188
|
+
|
|
1165
1189
|
SCANNED_SENSITIVE = {
|
|
1166
1190
|
locking: "is the optimistic-locking column — kept because the permit call lists it: an edit form that " \
|
|
1167
1191
|
"round-trips it is how Rails detects a stale update; never give it a default:",
|
|
@@ -1320,9 +1344,22 @@ module Permittable
|
|
|
1320
1344
|
return nil if rules.empty?
|
|
1321
1345
|
|
|
1322
1346
|
bodies = rules.map do |actions, body|
|
|
1323
|
-
"#{signature(root: root, model: model, actions: actions)}\n
|
|
1347
|
+
"#{one_line(signature(root: root, model: model, actions: actions))}\n" \
|
|
1348
|
+
"#{body.map { |line| " #{one_line(line)}\n" }.join}end\n"
|
|
1324
1349
|
end
|
|
1325
1350
|
HEADER + bodies.join("\n#{UPDATE_NOTE}")
|
|
1326
1351
|
end
|
|
1352
|
+
|
|
1353
|
+
# Every drafted line passes through here, the one place text from the
|
|
1354
|
+
# database or the app (a column name in a TODO) meets a comment. A line
|
|
1355
|
+
# break in it would end that comment and leave the rest as live Ruby for
|
|
1356
|
+
# whoever pastes the draft. Ruby's lexer ends a comment at "\n" only (a
|
|
1357
|
+
# lone "\r", "\v", "\f", U+0085, U+2028/9 and NUL stay inside it), but an
|
|
1358
|
+
# editor or terminal shows "\r" as a break too, so it goes as well. A name
|
|
1359
|
+
# inside a quoted literal is already escaped by `inspect`, with no raw
|
|
1360
|
+
# break left.
|
|
1361
|
+
def one_line(line)
|
|
1362
|
+
line.gsub(/\r\n|[\r\n]/, " ")
|
|
1363
|
+
end
|
|
1327
1364
|
end
|
|
1328
1365
|
end
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -197,32 +197,44 @@ module Permittable
|
|
|
197
197
|
# required field (missing).
|
|
198
198
|
def request_body_for(controller, action)
|
|
199
199
|
rule = controller.permit_rule_for(action)
|
|
200
|
-
rule && rule_request_body(rule)
|
|
200
|
+
rule && rule_request_body(rule, controller)
|
|
201
201
|
end
|
|
202
202
|
|
|
203
|
-
def rule_request_body(rule)
|
|
203
|
+
def rule_request_body(rule, controller)
|
|
204
204
|
media_type = body_media_type(rule)
|
|
205
205
|
schema = JsonSchema.rule(rule)
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
exempt = exempt_keys(controller)
|
|
207
|
+
schema = form_schema(schema, rule, exempt) if media_type == "multipart/form-data"
|
|
208
|
+
allow_form_keys!(schema, exempt) if media_type != "multipart/form-data" && rule[:unknown] == :error && !rule[:root]
|
|
208
209
|
{
|
|
209
210
|
"required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
|
|
210
211
|
"content" => { media_type => { "schema" => schema } }
|
|
211
212
|
}
|
|
212
213
|
end
|
|
213
214
|
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
215
|
+
# The top-level keys the runtime exempts under `unknown: :error`: the
|
|
216
|
+
# constant list it subtracts (UNCHECKED_TOP_LEVEL_KEYS) plus the CSRF
|
|
217
|
+
# parameter the controller renamed, which it reads off the controller too
|
|
218
|
+
# (permittable_configured_csrf_key). Per-instance overrides and an image
|
|
219
|
+
# submit's x/y pair cannot be known here.
|
|
220
|
+
def exempt_keys(controller)
|
|
221
|
+
token = controller.request_forgery_protection_token if controller.respond_to?(:request_forgery_protection_token)
|
|
222
|
+
(Permittable::UNCHECKED_TOP_LEVEL_KEYS + [token&.to_s]).compact.uniq
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Under `unknown: :error` the runtime still exempts Rails' routing and form
|
|
226
|
+
# keys (`exempt`, see exempt_keys) at the top of a rootless contract, so a
|
|
227
|
+
# JSON body carrying `authenticity_token`, `format` or `_method` (a form's
|
|
228
|
+
# fields sent as JSON, a record echoed back) is accepted, and the body
|
|
229
|
+
# schema must not forbid it. They
|
|
218
230
|
# stay in as free, never-required properties, as the multipart body lists
|
|
219
231
|
# them (see form_schema). A name the contract declares keeps its own
|
|
220
232
|
# schema. A `root:` rule needs none of this: the keys sit outside the root,
|
|
221
233
|
# in a wrapper that is permissive already. The plain JsonSchema.rule and
|
|
222
234
|
# Contract#json_schema describe the contract, not an HTTP body, and a
|
|
223
235
|
# standalone Contract exempts no form key, so they do not list them.
|
|
224
|
-
def allow_form_keys!(schema)
|
|
225
|
-
|
|
236
|
+
def allow_form_keys!(schema, exempt)
|
|
237
|
+
exempt.each { |key| schema["properties"][key] ||= {} }
|
|
226
238
|
end
|
|
227
239
|
|
|
228
240
|
# A multipart body is not one JSON object: each part is a form field, and
|
|
@@ -243,15 +255,15 @@ module Permittable
|
|
|
243
255
|
# nullable container's null (`user[docs]=""`), the keys of a :json
|
|
244
256
|
# field (`user[extra][any]`), and, for a `root:` rule, any part outside
|
|
245
257
|
# the root, which the runtime never reads; a rootless rule instead gets
|
|
246
|
-
# the
|
|
247
|
-
def form_schema(json_schema, rule)
|
|
258
|
+
# the keys (`exempt`, see exempt_keys) the runtime exempts at the top level.
|
|
259
|
+
def form_schema(json_schema, rule, exempt)
|
|
248
260
|
root = rule[:root] && rule[:root].to_s
|
|
249
261
|
form = { properties: {}, patterns: {}, required: [], groups: {}, strict: rule[:unknown] == :error }
|
|
250
262
|
flatten_form(json_schema, [], true, form, skip_group: root)
|
|
251
263
|
if form[:strict] && root
|
|
252
264
|
form[:patterns]["^(?!#{form_escape(root)}(?:\\[|$))"] = {}
|
|
253
265
|
elsif form[:strict]
|
|
254
|
-
form[:patterns]["^(?:#{
|
|
266
|
+
form[:patterns]["^(?:#{exempt.map { |key| form_escape(key) }.join('|')})$"] = {}
|
|
255
267
|
end
|
|
256
268
|
schema = { "type" => "object", "properties" => form[:properties] }
|
|
257
269
|
schema["patternProperties"] = form[:patterns] unless form[:patterns].empty?
|
|
@@ -390,24 +402,23 @@ module Permittable
|
|
|
390
402
|
end
|
|
391
403
|
|
|
392
404
|
def operation_for(controller, action)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
end
|
|
405
|
+
# "*" is the catch-all's marker on a plain host; a rule that names "*"
|
|
406
|
+
# literally is an ordinary action and is found through permit_rule_for.
|
|
407
|
+
rule = (action == "*" && controller.permittable_contracts.reverse_each.find { |r| r[:actions].empty? }) ||
|
|
408
|
+
controller.permit_rule_for(action)
|
|
398
409
|
operation = {}
|
|
399
410
|
key = controller_key(controller)
|
|
400
411
|
operation["operationId"] = "#{key.tr('/', '_')}_#{action}" if key && action != "*"
|
|
401
412
|
# dup: the stored desc is frozen, and a caller editing the generated
|
|
402
413
|
# document in place must neither raise nor reach the contract.
|
|
403
414
|
operation["description"] = rule[:desc].dup if rule[:desc]
|
|
404
|
-
operation["requestBody"] = rule_request_body(rule)
|
|
415
|
+
operation["requestBody"] = rule_request_body(rule, controller)
|
|
405
416
|
operation["responses"] = responses_for(rule)
|
|
406
417
|
# The docs must not promise a 422 the server doesn't yet send. Only
|
|
407
418
|
# the rule's own declaration is contract data — the app-wide
|
|
408
419
|
# Permittable.mode is runtime configuration the export can't see.
|
|
409
420
|
operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
|
|
410
|
-
operation["x-permittable-catch-all"] = true if action == "*"
|
|
421
|
+
operation["x-permittable-catch-all"] = true if action == "*" && rule[:actions].empty?
|
|
411
422
|
# `finalize` is flagged on the rule's object schema too (see
|
|
412
423
|
# JsonSchema.rule), but that sits inside the requestBody, and an
|
|
413
424
|
# operation can end up with no body (every field a path variable) while
|
|
@@ -588,7 +599,7 @@ module Permittable
|
|
|
588
599
|
else
|
|
589
600
|
free.each do |path, verb, template, defaults|
|
|
590
601
|
holder = (paths[path] ||= {})
|
|
591
|
-
placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path, defaults)
|
|
602
|
+
placed = with_path_parameters(operation, path, controller.permit_rule_for(action), controller, [template || path, defaults])
|
|
592
603
|
holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
|
|
593
604
|
slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
|
|
594
605
|
end
|
|
@@ -707,6 +718,9 @@ module Permittable
|
|
|
707
718
|
# otherwise: a rootless contract field named like one is that parameter's
|
|
708
719
|
# schema (path_fields), and leaves the body schema.
|
|
709
720
|
#
|
|
721
|
+
# `route` is `[own, defaults]`; `controller` supplies the keys the body
|
|
722
|
+
# exempts (see exempt_keys).
|
|
723
|
+
#
|
|
710
724
|
# `own` is the route's own spelling of the template, which differs
|
|
711
725
|
# from `path` when collapse_shapes placed the route under another
|
|
712
726
|
# route's spelling: the same shape, so the names pair up by position.
|
|
@@ -717,7 +731,8 @@ module Permittable
|
|
|
717
731
|
# not carry them and they are no path parameter. A name the path
|
|
718
732
|
# templates is that parameter instead: `get "o(/:id)", defaults:
|
|
719
733
|
# { id: 5 }` is a parameter on `/o/{id}` and a default only on `/o`.
|
|
720
|
-
def with_path_parameters(operation, path, rule,
|
|
734
|
+
def with_path_parameters(operation, path, rule, controller, route)
|
|
735
|
+
own, defaults = route
|
|
721
736
|
# A name the path templates twice (a mount and an engine route both
|
|
722
737
|
# name `:id`) is declared once: OpenAPI forbids two parameters with
|
|
723
738
|
# one name and location.
|
|
@@ -736,7 +751,7 @@ module Permittable
|
|
|
736
751
|
operation.each_with_object({}) do |(key, value), out|
|
|
737
752
|
if key == "requestBody"
|
|
738
753
|
out["parameters"] = parameters unless parameters.empty?
|
|
739
|
-
value = body_without(rule, fields.values + default_fields(rule, supplied), own_names + supplied)
|
|
754
|
+
value = body_without(rule, controller, fields.values + default_fields(rule, supplied), own_names + supplied)
|
|
740
755
|
end
|
|
741
756
|
out[key] = value unless value.nil?
|
|
742
757
|
end
|
|
@@ -797,11 +812,11 @@ module Permittable
|
|
|
797
812
|
# nothing in the body can make the request wrong. A name the route's
|
|
798
813
|
# `defaults:` fill is treated the same. A rooted contract
|
|
799
814
|
# needs none of this: its wrapper object is permissive already.
|
|
800
|
-
def body_without(rule, moved, templated)
|
|
815
|
+
def body_without(rule, controller, moved, templated)
|
|
801
816
|
rest = rule[:fields] - moved
|
|
802
817
|
return if rest.empty? && !moved.empty?
|
|
803
818
|
|
|
804
|
-
body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
|
|
819
|
+
body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest), controller)
|
|
805
820
|
return body unless rule[:unknown] == :error && !rule[:root]
|
|
806
821
|
|
|
807
822
|
properties = body["content"].values.first["schema"]["properties"]
|
|
@@ -1028,13 +1043,15 @@ module Permittable
|
|
|
1028
1043
|
# The names a route fills from its `defaults:` (`scope`, `resources` and
|
|
1029
1044
|
# `mount` take them too), as strings. The router puts them into
|
|
1030
1045
|
# path_parameters beside the URL's own, so the client cannot set them;
|
|
1031
|
-
# `controller
|
|
1032
|
-
#
|
|
1046
|
+
# `controller` and `action` are the router's own keys and are left
|
|
1047
|
+
# out, as the runtime leaves them out of `unknown:`. `format` is left in:
|
|
1048
|
+
# a `defaults: { format: ... }` lands in path_parameters like any other
|
|
1049
|
+
# default, so a contract field of that name is filled by it. A
|
|
1033
1050
|
# hand-built route that cannot say fills nothing.
|
|
1034
1051
|
def route_defaults(route)
|
|
1035
1052
|
return [] unless route.respond_to?(:defaults)
|
|
1036
1053
|
|
|
1037
|
-
route.defaults.keys.map(&:to_s) - %w[controller action
|
|
1054
|
+
route.defaults.keys.map(&:to_s) - %w[controller action]
|
|
1038
1055
|
end
|
|
1039
1056
|
|
|
1040
1057
|
# The Prefix of each variant of a mount's path under each of `prefixes`,
|
data/lib/permittable/rspec.rb
CHANGED
|
@@ -669,6 +669,10 @@ module Permittable
|
|
|
669
669
|
"expected #{subject_name} not to #{verb} those params#{action_label}, but it did"
|
|
670
670
|
end
|
|
671
671
|
|
|
672
|
+
# The root a scoped filter_parameters entry needs comes from the subject,
|
|
673
|
+
# which a matcher only learns in matches?. Before any run there is none to
|
|
674
|
+
# resolve, so a description read then prints through the shallow filter
|
|
675
|
+
# (RSpec builds a one-liner's name after the run, so it is not affected).
|
|
672
676
|
def description
|
|
673
677
|
label = @expect_accepted ? "accept those params" : "reject those params"
|
|
674
678
|
label += action_label
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -642,8 +642,56 @@ module Permittable
|
|
|
642
642
|
end
|
|
643
643
|
end
|
|
644
644
|
|
|
645
|
+
# Remembers what `skip_before_action :enforce_params_contract` took out of
|
|
646
|
+
# the callback chain, for Audit. A skip with only:/except:/if:/unless: leaves
|
|
647
|
+
# the callback in the chain behind conditions Rails keeps private, so the
|
|
648
|
+
# only public place to read them is the call itself. Prepended to the
|
|
649
|
+
# controller's singleton class when the gem registers its callback, so it
|
|
650
|
+
# wins whatever the include order of the callbacks module and this one.
|
|
651
|
+
# It calls super with the arguments it was given and returns what super
|
|
652
|
+
# returns; it only looks.
|
|
653
|
+
module SkipRecorder
|
|
654
|
+
ruby2_keywords def skip_before_action(*args, &)
|
|
655
|
+
# A copy: Rails deletes only:/except: from a positional Hash and adds :if to it.
|
|
656
|
+
options = args.last.is_a?(Hash) ? args.last.dup : {}
|
|
657
|
+
return super unless args.any? { |name| name.to_s == "enforce_params_contract" }
|
|
658
|
+
|
|
659
|
+
# Rails rebuilds the callback of every class already below this one too,
|
|
660
|
+
# each with its own new object, so each gets the record.
|
|
661
|
+
targets = [self] + descendants
|
|
662
|
+
before = targets.map(&:permittable_hook_callback)
|
|
663
|
+
result = super
|
|
664
|
+
targets.zip(before) { |target, was| target.permittable_record_skip(was, options) }
|
|
665
|
+
result
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def permittable_hook_callback
|
|
669
|
+
_process_action_callbacks.find { |callback| callback.kind == :before && callback.filter == :enforce_params_contract }
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# One state per class, replaced rather than changed, so a subclass's skip
|
|
673
|
+
# never reaches its parent. It holds the callback now in the chain and
|
|
674
|
+
# every skip that produced it: a skip is carried forward from the state
|
|
675
|
+
# only when it was made on that very callback, so a callback declared
|
|
676
|
+
# again afterwards (a new object) starts clean. only:/except: are kept as
|
|
677
|
+
# Rails compares them, action names as Strings.
|
|
678
|
+
def permittable_record_skip(before, options)
|
|
679
|
+
after = permittable_hook_callback
|
|
680
|
+
return if before.nil? || before.equal?(after)
|
|
681
|
+
|
|
682
|
+
held = permittable_skips
|
|
683
|
+
skips = held && held[:callback].equal?(before) ? held[:skips] : []
|
|
684
|
+
skip = { only: (Array(options[:only]).to_set(&:to_s) if options[:only]),
|
|
685
|
+
except: (Array(options[:except]).to_set(&:to_s) if options[:except]),
|
|
686
|
+
conditional: options.key?(:if) || options.key?(:unless) }.freeze
|
|
687
|
+
self.permittable_skips = { callback: after, skips: (skips + [skip]).freeze }.freeze
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
645
691
|
included do
|
|
646
692
|
class_attribute :permittable_contracts, instance_accessor: false, default: []
|
|
693
|
+
# The skips of enforce_params_contract SkipRecorder saw (see Audit).
|
|
694
|
+
class_attribute :permittable_skips, instance_accessor: false, default: nil
|
|
647
695
|
# Whether the gem itself ever registered its rescue_from / before_action
|
|
648
696
|
# for this class (a subclass inherits the answer). Asking the callback
|
|
649
697
|
# chain instead would bring back a check the app took out with
|
|
@@ -1006,9 +1054,11 @@ module Permittable
|
|
|
1006
1054
|
result.exponent.abs > [DECIMAL_DIGIT_ALLOWANCE, source.length].max
|
|
1007
1055
|
end
|
|
1008
1056
|
|
|
1057
|
+
# eql?, not ==: 1.0, BigDecimal("1"), Rational(1, 1) and Complex(1, 0) are
|
|
1058
|
+
# == 1, and a JSON `1.0` arrives as a Float, but only the Integer is listed.
|
|
1009
1059
|
def cast_boolean(value)
|
|
1010
|
-
return [:ok, true] if TRUE_VALUES.
|
|
1011
|
-
return [:ok, false] if FALSE_VALUES.
|
|
1060
|
+
return [:ok, true] if TRUE_VALUES.any? { |true_value| true_value.eql?(value) }
|
|
1061
|
+
return [:ok, false] if FALSE_VALUES.any? { |false_value| false_value.eql?(value) }
|
|
1012
1062
|
|
|
1013
1063
|
[:error, "invalid_type"]
|
|
1014
1064
|
end
|
|
@@ -1033,6 +1083,7 @@ module Permittable
|
|
|
1033
1083
|
date ? [:ok, date] : [:error, "invalid_type"]
|
|
1034
1084
|
when Date then [:ok, value]
|
|
1035
1085
|
when String
|
|
1086
|
+
value = value.delete_prefix(BOM) if value.start_with?(BOM)
|
|
1036
1087
|
# A bare ISO date ("2026-09-05", the common case) carries no zone, T
|
|
1037
1088
|
# or offset, so it skips every check for one.
|
|
1038
1089
|
plain = PLAIN_ISO_DATE.match?(value)
|
|
@@ -1048,6 +1099,7 @@ module Permittable
|
|
|
1048
1099
|
if !plain && (dropped = dropped_tail(value, found))
|
|
1049
1100
|
return date_at_midnight(*dropped)
|
|
1050
1101
|
end
|
|
1102
|
+
return [:error, "invalid_type"] if !plain && unread_text?(value, found)
|
|
1051
1103
|
|
|
1052
1104
|
# Built from the components rather than re-running Date.parse, which
|
|
1053
1105
|
# would parse the same string a second time — and Date.parse is the
|
|
@@ -1091,9 +1143,8 @@ module Permittable
|
|
|
1091
1143
|
|
|
1092
1144
|
# The Date of an instant read from a String when it is midnight UTC.
|
|
1093
1145
|
def date_at_midnight(status, instant)
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
[:ok, Date.new(instant.year, instant.month, instant.day)]
|
|
1146
|
+
date = date_of_instant(instant) if status == :ok
|
|
1147
|
+
date ? [:ok, date] : [:error, "invalid_type"]
|
|
1097
1148
|
end
|
|
1098
1149
|
|
|
1099
1150
|
# The date a Time or DateTime IS, or nil: its UTC date when it is
|
|
@@ -1144,6 +1195,7 @@ module Permittable
|
|
|
1144
1195
|
when Time, DateTime then [:ok, value.to_time.getutc]
|
|
1145
1196
|
when Date then [:ok, Time.utc(value.year, value.month, value.day)]
|
|
1146
1197
|
when String
|
|
1198
|
+
value = value.delete_prefix(BOM) if value.start_with?(BOM)
|
|
1147
1199
|
# Same rule as :date — the DATE part must be named in full, or it is
|
|
1148
1200
|
# taken from today ("10:30" meant today at 10:30). An absent TIME part
|
|
1149
1201
|
# is fine and means midnight, which is the documented reading of a
|
|
@@ -1178,6 +1230,7 @@ module Permittable
|
|
|
1178
1230
|
|
|
1179
1231
|
dropped = dropped_tail(value, found)
|
|
1180
1232
|
return dropped if dropped
|
|
1233
|
+
return [:error, "invalid_type"] if unread_text?(value, found)
|
|
1181
1234
|
|
|
1182
1235
|
[:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
|
|
1183
1236
|
rescue ArgumentError, RangeError
|
|
@@ -1252,9 +1305,9 @@ module Permittable
|
|
|
1252
1305
|
|
|
1253
1306
|
# Trailing words, set apart by a space: a zone name ("EST", "Eastern
|
|
1254
1307
|
# Standard Time", "W. Europe Standard Time") or a day or era word. The
|
|
1255
|
-
# first word
|
|
1256
|
-
#
|
|
1257
|
-
ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
|
|
1308
|
+
# first word may be one letter, which is a military zone or the ISO "T"
|
|
1309
|
+
# and so no zone here, but still stands where an hour was lost.
|
|
1310
|
+
ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)?[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
|
|
1258
1311
|
|
|
1259
1312
|
# An era word at the start of such a tail: the parser reads it with the
|
|
1260
1313
|
# date ("Sep 5 2026 BC" is the year -2025), so it is not what follows.
|
|
@@ -1287,14 +1340,21 @@ module Permittable
|
|
|
1287
1340
|
# without it names the same date (and time). Only a String that ends in a
|
|
1288
1341
|
# signed number or a word is parsed again, so a plain "2026-09-05" is not.
|
|
1289
1342
|
def dropped_tail(value, found)
|
|
1343
|
+
# "at" joins a date to its time, and Ruby reads it as a zone (or drops it) when none follows.
|
|
1344
|
+
return [:error, "invalid_type"] if /\sat\s*\z/i.match?(value)
|
|
1290
1345
|
return [:error, "invalid_type"] if !found.key?(:hour) && ISO_DATE_T_START.match?(value)
|
|
1291
1346
|
return if found.key?(:zone)
|
|
1292
1347
|
|
|
1293
1348
|
if (tail = SIGNED_TAIL.match(value))
|
|
1294
|
-
|
|
1349
|
+
# An offset glued to a zone name with no time ("2026-09-05 GMT+5") is
|
|
1350
|
+
# read below as midnight at that offset; one glued to a date is a
|
|
1351
|
+
# part of it ("2026-09-05" ends in "-05").
|
|
1352
|
+
named = !tail[1] && !found.key?(:hour) && /\s[[:alpha:]]+\z/.match(tail.pre_match)
|
|
1353
|
+
return unless tail[1] || found.key?(:hour) || named
|
|
1295
1354
|
|
|
1296
1355
|
rest = Date._parse(tail.pre_match)
|
|
1297
1356
|
return unless same_date?(rest, found)
|
|
1357
|
+
return glued_zone(named, tail[0].strip, found) if named
|
|
1298
1358
|
|
|
1299
1359
|
git_style = tail[1] && found.key?(:hour) && TIME_THEN_YEAR.match?(tail.pre_match) && same_time?(rest, found)
|
|
1300
1360
|
git_style ? instant_in_zone(tail.pre_match, tail[0].strip) : [:error, "invalid_type"]
|
|
@@ -1305,40 +1365,129 @@ module Permittable
|
|
|
1305
1365
|
pre += era[0]
|
|
1306
1366
|
text = era.post_match
|
|
1307
1367
|
end
|
|
1308
|
-
|
|
1368
|
+
# The era is part of the date, so the hour a lost one hid is looked
|
|
1369
|
+
# for before it ("2026-09-05 10 A.D.", "2026-09-05 10 AD EST").
|
|
1370
|
+
head = era ? Date._parse(tail.pre_match) : nil
|
|
1371
|
+
return same_time?(head, found) ? nil : [:error, "invalid_type"] if text.empty?
|
|
1309
1372
|
|
|
1310
1373
|
rest = Date._parse(pre)
|
|
1311
1374
|
return unless same_date?(rest, found)
|
|
1312
1375
|
|
|
1313
1376
|
zone = Date._parse("00:00 #{text}")
|
|
1314
1377
|
return unless zone.key?(:zone) # a day name
|
|
1378
|
+
# The hour the tail made the parser lose ("2026-09-05 10 EST").
|
|
1379
|
+
return [:error, "invalid_type"] unless same_time?(head || rest, found)
|
|
1315
1380
|
# A single letter is a military zone, which is no zone here: alone it
|
|
1316
1381
|
# is nothing ("2026-09-05 a", "A."), but the rest of an initial-led
|
|
1317
1382
|
# name Ruby did not know ("W. Europe") is dropped, so it is refused
|
|
1318
1383
|
# like any other word it does not know.
|
|
1319
1384
|
return text.match?(/\A[[:alpha:]]\.?\z/) ? nil : [:error, "invalid_type"] if zone[:zone].length == 1
|
|
1320
|
-
# The hour the tail made the parser lose ("2026-09-05 10 EST").
|
|
1321
|
-
return [:error, "invalid_type"] unless same_time?(rest, found)
|
|
1322
1385
|
|
|
1323
1386
|
instant_in_zone(pre, text)
|
|
1324
1387
|
end
|
|
1325
1388
|
end
|
|
1326
1389
|
|
|
1390
|
+
# A signed offset after a zone name ("GMT+5", "UTC+0530") is that zone to
|
|
1391
|
+
# Ruby when it reads it next to a time, so the String is midnight at it.
|
|
1392
|
+
# A name it does not read whole ("EST+5") or honour ("UTC+99") is refused.
|
|
1393
|
+
# What comes before the zone is held to the rules of any date String: no
|
|
1394
|
+
# hour a lost tail hid ("2026-09-05 10 AD GMT+5"), no second zone, and the
|
|
1395
|
+
# offset is hours ("+5", "+05") or hhmm with minutes a minute ("+0530").
|
|
1396
|
+
def glued_zone(word, offset, found)
|
|
1397
|
+
zone = "#{word[0].strip}#{offset}"
|
|
1398
|
+
pre = Date._parse(word.pre_match)
|
|
1399
|
+
return [:error, "invalid_type"] unless offset.match?(GLUED_OFFSET) && Date._parse("00:00 #{zone}")[:zone] == zone
|
|
1400
|
+
return [:error, "invalid_type"] unless same_time?(pre, found) && !pre.key?(:zone) && dropped_tail(word.pre_match, pre).nil?
|
|
1401
|
+
|
|
1402
|
+
instant_in_zone(word.pre_match, zone)
|
|
1403
|
+
end
|
|
1404
|
+
|
|
1327
1405
|
# The wall clock `date` names, as it is read in the zone `text` names:
|
|
1328
1406
|
# [:ok, instant], or an error when Ruby cannot honour the zone.
|
|
1329
1407
|
def instant_in_zone(date, text)
|
|
1330
1408
|
zone = Date._parse("00:00 #{text}")
|
|
1331
1409
|
return [:error, "invalid_type"] unless zone.key?(:zone) && honoured_zone?(zone)
|
|
1410
|
+
# Ruby reads a zone name and drops what follows it ("EST xyz" is EST).
|
|
1411
|
+
return [:error, "invalid_type"] unless zone[:zone].casecmp?(text.split.join(" "))
|
|
1412
|
+
return [:error, "invalid_type"] if unread_text?(date, Date._parse(date))
|
|
1332
1413
|
|
|
1333
1414
|
[:ok, DateTime.parse(date, true, Date::GREGORIAN).to_time.utc - zone[:offset]]
|
|
1334
1415
|
end
|
|
1335
1416
|
|
|
1417
|
+
# Text Date._parse read nothing from, which the parse then leaves out of
|
|
1418
|
+
# the result without a word: "2026-01-01xyz", a second date after the
|
|
1419
|
+
# first ("2026-01-15\n2099-12-31"), "<script>", and any control or
|
|
1420
|
+
# zero-width character (the parser takes it for a space). Found by
|
|
1421
|
+
# leaving each word and number out in turn: if the parse is unchanged,
|
|
1422
|
+
# that part of the String meant nothing to it.
|
|
1423
|
+
#
|
|
1424
|
+
# A part may stay unread only when it says nothing new: the T of an ISO
|
|
1425
|
+
# date-time, an ordinal or era word, "at", a lone letter after a date, an am
|
|
1426
|
+
# or pm the hour already says, or an offset or zone name that repeats the
|
|
1427
|
+
# one read ("EDT -04:00", "+0000 UTC"; "-0400 PST" disagrees, so is
|
|
1428
|
+
# refused). Remarks are set aside: a bracketed name ("(Central Daylight
|
|
1429
|
+
# Time)", "(heure d'été de l'Est)"), an RFC 9557 zone id
|
|
1430
|
+
# ("[Europe/London]", "[u-ca=japanese]": the offset governs) and Go's "m=+0.1".
|
|
1431
|
+
def unread_text?(value, found)
|
|
1432
|
+
return false if ISO_DATE_TIME.match?(value) || PLAIN_ISO_DATE.match?(value) # all of it is read; skips the parses below
|
|
1433
|
+
return true if value.match?(INVISIBLE)
|
|
1434
|
+
|
|
1435
|
+
text = value.gsub(REMARK, " ")
|
|
1436
|
+
# A remark the parser read (a "(Sat)") is part of the parse: look at it too.
|
|
1437
|
+
text = value if text != value && Date._parse(text) != found
|
|
1438
|
+
# "(GMT+02:00)" after "GMT+0200": a remark that repeats the offset read.
|
|
1439
|
+
text = text.gsub(OFFSET_REMARK) do
|
|
1440
|
+
Date._parse("00:00 GMT#{Regexp.last_match(1)}")[:offset] == found[:offset] ? " " : Regexp.last_match(0)
|
|
1441
|
+
end
|
|
1442
|
+
text = text.gsub(%r{[^-+',./:@[:alnum:]\[\]]+}, " ")
|
|
1443
|
+
return true if text.scan(PART).size > MAX_PARTS # each part below costs a parse
|
|
1444
|
+
|
|
1445
|
+
text.scan(PART) do
|
|
1446
|
+
part = Regexp.last_match
|
|
1447
|
+
next if part[0].match?(part.pre_match.match?(/\d\z/) ? GLUED_NOISE : LONE_WORD)
|
|
1448
|
+
next if part[0].casecmp?("at") && found.key?(:hour) && part.post_match.match?(/\d/) # joins a date to its time
|
|
1449
|
+
# "10:30 am", "12 pm" and "22:00 pm" are what the hour already says.
|
|
1450
|
+
next if { "am" => found[:hour].to_i < 12, "pm" => found[:hour].to_i >= 12 }[part[0].downcase]
|
|
1451
|
+
next unless Date._parse("#{part.pre_match} #{part.post_match}") == found # the parse needed this part
|
|
1452
|
+
|
|
1453
|
+
# What it left unread may still repeat the zone read, or label it.
|
|
1454
|
+
zone = Date._parse("00:00 #{part[0]}")[:offset] if found[:offset]
|
|
1455
|
+
next if zone && (zone == found[:offset] || offset_label?(found, part))
|
|
1456
|
+
|
|
1457
|
+
return true
|
|
1458
|
+
end
|
|
1459
|
+
false
|
|
1460
|
+
end
|
|
1461
|
+
|
|
1462
|
+
# "+0800 CST": a zone abbreviation right after the numeric offset read is a
|
|
1463
|
+
# label for it (Go and `date` print one), even where Ruby's table maps it to
|
|
1464
|
+
# another offset. The other order ("CST +08:00") reads the abbreviation, so no.
|
|
1465
|
+
def offset_label?(found, part)
|
|
1466
|
+
found[:zone].match?(/\A[+-]/) && part[0].match?(/\A[[:alpha:]]+\z/) && part.pre_match.rstrip.end_with?(found[:zone])
|
|
1467
|
+
end
|
|
1468
|
+
|
|
1336
1469
|
def same_time?(rest, found)
|
|
1337
1470
|
TIME_PARTS.all? { |part| rest[part] == found[part] }
|
|
1338
1471
|
end
|
|
1339
1472
|
|
|
1340
1473
|
TIME_PARTS = %i[hour min sec sec_fraction].freeze
|
|
1341
|
-
|
|
1474
|
+
GLUED_OFFSET = /\A[+-](?:\d{1,2}|\d{2}[0-5]\d)\z/
|
|
1475
|
+
ISO_DATE_TIME = /\A\d{4}-?\d\d-?\d\d[Tt ]\d\d:?\d\d(?::?\d\d(?:[.,]\d+)?)?(?:[Zz]| ?[+-]\d\d(?::?\d\d)?)?\z/
|
|
1476
|
+
# One leading U+FEFF is a UTF-8 signature (a CSV or text file saved with one), not text.
|
|
1477
|
+
BOM = "\uFEFF".freeze
|
|
1478
|
+
INVISIBLE = /[\p{Cc}\p{Cf}&&[^\s]]/
|
|
1479
|
+
REMARK_TEXT = /[\p{L}\p{M} .'’,&-]/
|
|
1480
|
+
REMARK = %r{\((?:#{REMARK_TEXT}|\(#{REMARK_TEXT}*\))*\)|(?:\[!?[\w/+=:-]+\])+\z|\sm=[+-]\d+(?:\.\d+)?\z}
|
|
1481
|
+
# No date format in use has more parts than this: the longest read in the
|
|
1482
|
+
# specs and corpora has 8, e.g. "September 5, 2026 at 10:00:00 AM GMT+2".
|
|
1483
|
+
MAX_PARTS = 16
|
|
1484
|
+
PART = /[[:alpha:]]+|[+-]?\d[^[:alpha:]\s]*/
|
|
1485
|
+
GLUED_NOISE = /\A(?:t|st|nd|rd|th)\z/i
|
|
1486
|
+
# No /i: Onigmo then lets [[:alpha:]] match a ligature's folding ("st", "ss", "ff") once it has met non-ASCII text.
|
|
1487
|
+
LONE_WORD = /\A(?:[[:alpha:]]|[Aa][Dd]|[Cc][Ee])\z/
|
|
1488
|
+
OFFSET_REMARK = /\((?:GMT|UTC)([+-]\d{1,2}(?::?\d{2})?)\)/
|
|
1489
|
+
private_constant :TIME_PARTS, :GLUED_OFFSET, :ISO_DATE_TIME, :BOM, :INVISIBLE, :REMARK_TEXT, :REMARK, :MAX_PARTS, :PART,
|
|
1490
|
+
:GLUED_NOISE, :LONE_WORD, :OFFSET_REMARK
|
|
1342
1491
|
|
|
1343
1492
|
def same_date?(rest, found)
|
|
1344
1493
|
complete_date?(rest) && rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
|
|
@@ -2443,6 +2592,7 @@ module Permittable
|
|
|
2443
2592
|
# order: this check runs before any callback declared after the include,
|
|
2444
2593
|
# so a locale switch that must cover it is a `prepend_around_action`.
|
|
2445
2594
|
before_action :enforce_params_contract
|
|
2595
|
+
singleton_class.prepend(SkipRecorder) unless singleton_class < SkipRecorder
|
|
2446
2596
|
self.permittable_callback_registered = true
|
|
2447
2597
|
end
|
|
2448
2598
|
|
|
@@ -2453,7 +2603,7 @@ module Permittable
|
|
|
2453
2603
|
# Only the rule's OWN declared mode is read, never Permittable.mode: that
|
|
2454
2604
|
# is usually set by an initializer, and an app must not stop booting over
|
|
2455
2605
|
# it. The effective mode can only add a requirement, so an action it makes
|
|
2456
|
-
# monitored without a before_action is left to Audit.
|
|
2606
|
+
# monitored without a before_action is left to Audit.inert_reason, which reports
|
|
2457
2607
|
# it as not covered.
|
|
2458
2608
|
def permittable_ensure_runnable!(rule)
|
|
2459
2609
|
return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
|
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.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|