permittable 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e1419e11eb73f0b94274aa8f6a868e81913f64a754bfecde44885e83a825942
4
- data.tar.gz: 74a084502a3328e692fd522c719d16e8242fb2a12a9602e3da76c3e6076be426
3
+ metadata.gz: 2284fdea0043c6364608ab68b43de16bbea66462f12a4f2e6ee5bdf47087fbb6
4
+ data.tar.gz: 8dd729f396cee1ba015179bd3ee8522754bc6312f4816810ed060134da8456d7
5
5
  SHA512:
6
- metadata.gz: de34efb031486908abdf6b26293108f83505dac300ce9a1977b88439c5677d058950a567bd79ead0aefe6f03605d7d312eede2215844393855beb098c99d2819
7
- data.tar.gz: 95420ccea0d5842aaea3877ed3343ab444c7bc9a505efdad76b1b0b23fae1ab7bb636ba367cb9e4ad69151ae35e9a2491c05d6746d6cd31b87bb41cb5744c1be
6
+ metadata.gz: 239926696fff412e35a4ef9bf0f392f92006c90a9ff8d69779a575eb71420ff441a433123a23c7d9e459d8eaf62d74bc9371aa1649bb739b4d89db60831d41ad
7
+ data.tar.gz: 04b5b1fe3b2993056a650f2f293a9a95e8c46f5c8bc5eb609929a0e93ae9e81591cc6434822fce5dcbfd06eb9ae4b17081165fdc817c91495abc59badba12339
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.15.1 (2026-11-12)
4
+ <!-- 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 -->
5
+
6
+ **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.
7
+
8
+ ### Fixed
9
+ - **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)
10
+ - **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)
11
+ - **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)
12
+ - **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)
13
+
3
14
  ## 0.15.0 (2026-11-12)
4
15
  <!-- 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
16
 
data/README.md CHANGED
@@ -605,7 +605,7 @@ optional :avatar, :file, validate: ->(file) { file.size <= 2.megabytes && file.c
605
605
  array :attachments, of: :file, length: 0..5
606
606
  ```
607
607
 
608
- `:file` takes `required:`, `nullable:`, `validate:`, `transform:`, `desc:`, `sensitive:`, `virtual:` and `message:`. `in:`, `length:`, `format:`, `normalize:`, `default:` and `example:` are refused at class load. A form's empty file input sends `""`, which is absent as everywhere else. The [drift guard](#the-schema-drift-guard) does not look for a column behind a `:file` field (an attachment is not a column), and the OpenAPI export documents an operation with a file as `multipart/form-data`, its properties named the way Rails reads the parts (`user[avatar]`, `docs[]`, `user[address][scan]`). An array of hashes is sent with an index (`items[0][photo]`, `items[0][name]`, `items[1][photo]`, …), as Rails' `fields_for` renders it, and the document describes those leaves with anchored `patternProperties`. What a flat body cannot carry, a container's own `required`, `minItems`/`maxItems`, `nullable`, `description` and custom-validation marker, is kept in the body's `x-permittable-form-groups`, keyed by the container's bracket name (`user[items]`), with `[]` standing for the index of a container inside an element (`user[items][][info]`). Send a nullable container's null as an empty part named by that bracket name, without `[]` (`user[docs]=""`; `user[docs][]=""` is an empty array). Under `unknown: :error` the multipart body sets `additionalProperties: false`, which refuses an undeclared part at any depth (`user[address][bogus]`, `user[items][0][bogus]`), and it lists the parts the server still takes: a nullable container's null as `{"const": ""}`, a `:json` field's keys (`user[extra][any]`) and, for a `root:` rule, any part outside the root, or for a rootless one, the form keys the runtime exempts (`authenticity_token`, `_method`, `utf8`, `commit`, `button`). A renamed CSRF token (`request_forgery_protection_token`) and an image submit's `x`/`y` cannot be known when the document is built, so they are not listed. The children of an optional hash are required only when the hash is sent (the group entry says the hash is optional), and `patternProperties` cannot mark a leaf of an array element required.
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 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
609
 
610
610
  An ActiveStorage direct upload (`form.file_field :avatar, direct_upload: true`) sends the file from the browser straight to the storage service, and the form then submits only the blob's signed id, a String, which a `:file` field refuses as `invalid_type`. Declare such a field `:string` and attach the signed id (`record.avatar.attach(signed_id)`):
611
611
 
@@ -700,7 +700,7 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
700
700
  | `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
701
701
  | `:file` | An upload object (`ActionDispatch::Http::UploadedFile`, `Rack::Test::UploadedFile`), passed through uncast | A `String` (including a form's file field sent as text), numbers, hashes, arrays |
702
702
 
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.
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 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.
704
704
 
705
705
  **Strings in other encodings are inspected, never converted.** A String whose bytes are not valid in its **own** encoding (`"caf\xC3"` in UTF-8, a lone UTF-16 surrogate) is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. Any other String keeps its encoding: a `:string` value is handed back exactly as it arrived, so a controller using Rails' `skip_parameter_encoding` or `param_encoding` gets its binary or Shift_JIS text unchanged. The number, boolean and date types parse a UTF-8 **copy** of the text, so UTF-16 `"12"` casts to `12` for an `:integer`; when the text has no UTF-8 reading (a byte Windows-1252 leaves undefined) that is `invalid_type`. `normalize:` and `format:` work on the String in its own encoding. Where a normalizer cannot handle that encoding (`:squish` on UTF-16), the value is left as it is; where a `format:` pattern cannot be applied to it (a non-ASCII pattern against UTF-16 or binary bytes), that is a `format` violation. `in:` compares Strings as Ruby does, encoding included. A `:json` field's contents are not examined.
706
706
 
@@ -928,7 +928,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
928
928
 
929
929
  Under `:log` that bound is the whole record: nothing else names an undeclared key, so beyond the tenth only the count survives. Where you need every name — auditing what a client really sends during a rollout — use `unknown: :error` in monitor mode, which records all of them in `details` and in the instrumentation payload without rejecting the request.
930
930
 
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 form keys above (`authenticity_token`, `_method`, `utf8`, `commit`, `button`) 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`, `action` and `format`. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router, a form, nor a request.
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 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
932
 
933
933
  All of this changes what is *checked* only. Monitor mode still hands back the form keys, the path parameters and the wrapper's copy in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `params[:id]` or `_method` itself; only the router's three are dropped there.
934
934
 
@@ -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
- schema = form_schema(schema, rule) if media_type == "multipart/form-data"
207
- allow_form_keys!(schema) if media_type != "multipart/form-data" && rule[:unknown] == :error && !rule[:root]
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
- # Under `unknown: :error` the runtime still exempts Rails' form keys
215
- # (FORM_KEYS) at the top of a rootless contract, so a JSON body carrying
216
- # `authenticity_token` or `_method` (a form's fields sent as JSON, a record
217
- # echoed back) is accepted, and the body schema must not forbid it. They
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
- Permittable::FORM_KEYS.each { |key| schema["properties"][key] ||= {} }
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 form keys (FORM_KEYS) the runtime exempts at the top level.
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]["^(?:#{Permittable::FORM_KEYS.map { |key| form_escape(key) }.join('|')})$"] = {}
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?
@@ -401,7 +413,7 @@ module Permittable
401
413
  # dup: the stored desc is frozen, and a caller editing the generated
402
414
  # document in place must neither raise nor reach the contract.
403
415
  operation["description"] = rule[:desc].dup if rule[:desc]
404
- operation["requestBody"] = rule_request_body(rule)
416
+ operation["requestBody"] = rule_request_body(rule, controller)
405
417
  operation["responses"] = responses_for(rule)
406
418
  # The docs must not promise a 422 the server doesn't yet send. Only
407
419
  # the rule's own declaration is contract data — the app-wide
@@ -588,7 +600,7 @@ module Permittable
588
600
  else
589
601
  free.each do |path, verb, template, defaults|
590
602
  holder = (paths[path] ||= {})
591
- placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path, defaults)
603
+ placed = with_path_parameters(operation, path, controller.permit_rule_for(action), controller, [template || path, defaults])
592
604
  holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
593
605
  slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
594
606
  end
@@ -707,6 +719,9 @@ module Permittable
707
719
  # otherwise: a rootless contract field named like one is that parameter's
708
720
  # schema (path_fields), and leaves the body schema.
709
721
  #
722
+ # `route` is `[own, defaults]`; `controller` supplies the keys the body
723
+ # exempts (see exempt_keys).
724
+ #
710
725
  # `own` is the route's own spelling of the template, which differs
711
726
  # from `path` when collapse_shapes placed the route under another
712
727
  # route's spelling: the same shape, so the names pair up by position.
@@ -717,7 +732,8 @@ module Permittable
717
732
  # not carry them and they are no path parameter. A name the path
718
733
  # templates is that parameter instead: `get "o(/:id)", defaults:
719
734
  # { id: 5 }` is a parameter on `/o/{id}` and a default only on `/o`.
720
- def with_path_parameters(operation, path, rule, own = path, defaults = [])
735
+ def with_path_parameters(operation, path, rule, controller, route)
736
+ own, defaults = route
721
737
  # A name the path templates twice (a mount and an engine route both
722
738
  # name `:id`) is declared once: OpenAPI forbids two parameters with
723
739
  # one name and location.
@@ -736,7 +752,7 @@ module Permittable
736
752
  operation.each_with_object({}) do |(key, value), out|
737
753
  if key == "requestBody"
738
754
  out["parameters"] = parameters unless parameters.empty?
739
- value = body_without(rule, fields.values + default_fields(rule, supplied), own_names + supplied)
755
+ value = body_without(rule, controller, fields.values + default_fields(rule, supplied), own_names + supplied)
740
756
  end
741
757
  out[key] = value unless value.nil?
742
758
  end
@@ -797,11 +813,11 @@ module Permittable
797
813
  # nothing in the body can make the request wrong. A name the route's
798
814
  # `defaults:` fill is treated the same. A rooted contract
799
815
  # needs none of this: its wrapper object is permissive already.
800
- def body_without(rule, moved, templated)
816
+ def body_without(rule, controller, moved, templated)
801
817
  rest = rule[:fields] - moved
802
818
  return if rest.empty? && !moved.empty?
803
819
 
804
- body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
820
+ body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest), controller)
805
821
  return body unless rule[:unknown] == :error && !rule[:root]
806
822
 
807
823
  properties = body["content"].values.first["schema"]["properties"]
@@ -1028,13 +1044,15 @@ module Permittable
1028
1044
  # The names a route fills from its `defaults:` (`scope`, `resources` and
1029
1045
  # `mount` take them too), as strings. The router puts them into
1030
1046
  # path_parameters beside the URL's own, so the client cannot set them;
1031
- # `controller`, `action` and `format` are the router's own keys and
1032
- # are left out, as the runtime leaves them out of `unknown:`. A
1047
+ # `controller` and `action` are the router's own keys and are left
1048
+ # out, as the runtime leaves them out of `unknown:`. `format` is left in:
1049
+ # a `defaults: { format: ... }` lands in path_parameters like any other
1050
+ # default, so a contract field of that name is filled by it. A
1033
1051
  # hand-built route that cannot say fills nothing.
1034
1052
  def route_defaults(route)
1035
1053
  return [] unless route.respond_to?(:defaults)
1036
1054
 
1037
- route.defaults.keys.map(&:to_s) - %w[controller action format]
1055
+ route.defaults.keys.map(&:to_s) - %w[controller action]
1038
1056
  end
1039
1057
 
1040
1058
  # The Prefix of each variant of a mount's path under each of `prefixes`,
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.15.0".freeze
2
+ VERSION = "0.15.1".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -1091,9 +1091,8 @@ module Permittable
1091
1091
 
1092
1092
  # The Date of an instant read from a String when it is midnight UTC.
1093
1093
  def date_at_midnight(status, instant)
1094
- return [:error, "invalid_type"] unless status == :ok && instant == instant.beginning_of_day
1095
-
1096
- [:ok, Date.new(instant.year, instant.month, instant.day)]
1094
+ date = date_of_instant(instant) if status == :ok
1095
+ date ? [:ok, date] : [:error, "invalid_type"]
1097
1096
  end
1098
1097
 
1099
1098
  # The date a Time or DateTime IS, or nil: its UTC date when it is
@@ -1252,9 +1251,9 @@ module Permittable
1252
1251
 
1253
1252
  # Trailing words, set apart by a space: a zone name ("EST", "Eastern
1254
1253
  # Standard Time", "W. Europe Standard Time") or a day or era word. The
1255
- # first word has two letters or more, or one and a ".", as a lone letter
1256
- # is a military zone or the ISO "T".
1257
- ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
1254
+ # first word may be one letter, which is a military zone or the ISO "T"
1255
+ # and so no zone here, but still stands where an hour was lost.
1256
+ ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)?[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
1258
1257
 
1259
1258
  # An era word at the start of such a tail: the parser reads it with the
1260
1259
  # date ("Sep 5 2026 BC" is the year -2025), so it is not what follows.
@@ -1291,10 +1290,15 @@ module Permittable
1291
1290
  return if found.key?(:zone)
1292
1291
 
1293
1292
  if (tail = SIGNED_TAIL.match(value))
1294
- return unless tail[1] || found.key?(:hour)
1293
+ # An offset glued to a zone name with no time ("2026-09-05 GMT+5") is
1294
+ # read below as midnight at that offset; one glued to a date is a
1295
+ # part of it ("2026-09-05" ends in "-05").
1296
+ named = !tail[1] && !found.key?(:hour) && /\s[[:alpha:]]+\z/.match(tail.pre_match)
1297
+ return unless tail[1] || found.key?(:hour) || named
1295
1298
 
1296
1299
  rest = Date._parse(tail.pre_match)
1297
1300
  return unless same_date?(rest, found)
1301
+ return glued_zone(named, tail[0].strip, found) if named
1298
1302
 
1299
1303
  git_style = tail[1] && found.key?(:hour) && TIME_THEN_YEAR.match?(tail.pre_match) && same_time?(rest, found)
1300
1304
  git_style ? instant_in_zone(tail.pre_match, tail[0].strip) : [:error, "invalid_type"]
@@ -1305,25 +1309,43 @@ module Permittable
1305
1309
  pre += era[0]
1306
1310
  text = era.post_match
1307
1311
  end
1308
- return if text.empty?
1312
+ # The era is part of the date, so the hour a lost one hid is looked
1313
+ # for before it ("2026-09-05 10 A.D.", "2026-09-05 10 AD EST").
1314
+ head = era ? Date._parse(tail.pre_match) : nil
1315
+ return same_time?(head, found) ? nil : [:error, "invalid_type"] if text.empty?
1309
1316
 
1310
1317
  rest = Date._parse(pre)
1311
1318
  return unless same_date?(rest, found)
1312
1319
 
1313
1320
  zone = Date._parse("00:00 #{text}")
1314
1321
  return unless zone.key?(:zone) # a day name
1322
+ # The hour the tail made the parser lose ("2026-09-05 10 EST").
1323
+ return [:error, "invalid_type"] unless same_time?(head || rest, found)
1315
1324
  # A single letter is a military zone, which is no zone here: alone it
1316
1325
  # is nothing ("2026-09-05 a", "A."), but the rest of an initial-led
1317
1326
  # name Ruby did not know ("W. Europe") is dropped, so it is refused
1318
1327
  # like any other word it does not know.
1319
1328
  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
1329
 
1323
1330
  instant_in_zone(pre, text)
1324
1331
  end
1325
1332
  end
1326
1333
 
1334
+ # A signed offset after a zone name ("GMT+5", "UTC+0530") is that zone to
1335
+ # Ruby when it reads it next to a time, so the String is midnight at it.
1336
+ # A name it does not read whole ("EST+5") or honour ("UTC+99") is refused.
1337
+ # What comes before the zone is held to the rules of any date String: no
1338
+ # hour a lost tail hid ("2026-09-05 10 AD GMT+5"), no second zone, and the
1339
+ # offset is hours ("+5", "+05") or hhmm with minutes a minute ("+0530").
1340
+ def glued_zone(word, offset, found)
1341
+ zone = "#{word[0].strip}#{offset}"
1342
+ pre = Date._parse(word.pre_match)
1343
+ return [:error, "invalid_type"] unless offset.match?(GLUED_OFFSET) && Date._parse("00:00 #{zone}")[:zone] == zone
1344
+ return [:error, "invalid_type"] unless same_time?(pre, found) && !pre.key?(:zone) && dropped_tail(word.pre_match, pre).nil?
1345
+
1346
+ instant_in_zone(word.pre_match, zone)
1347
+ end
1348
+
1327
1349
  # The wall clock `date` names, as it is read in the zone `text` names:
1328
1350
  # [:ok, instant], or an error when Ruby cannot honour the zone.
1329
1351
  def instant_in_zone(date, text)
@@ -1338,7 +1360,8 @@ module Permittable
1338
1360
  end
1339
1361
 
1340
1362
  TIME_PARTS = %i[hour min sec sec_fraction].freeze
1341
- private_constant :TIME_PARTS
1363
+ GLUED_OFFSET = /\A[+-](?:\d{1,2}|\d{2}[0-5]\d)\z/
1364
+ private_constant :TIME_PARTS, :GLUED_OFFSET
1342
1365
 
1343
1366
  def same_date?(rest, found)
1344
1367
  complete_date?(rest) && rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
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.15.0
4
+ version: 0.15.1
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 00:00:00.000000000 Z
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport