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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +3 -3
- data/lib/permittable/open_api.rb +40 -22
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +34 -11
- 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: 2284fdea0043c6364608ab68b43de16bbea66462f12a4f2e6ee5bdf47087fbb6
|
|
4
|
+
data.tar.gz: 8dd729f396cee1ba015179bd3ee8522754bc6312f4816810ed060134da8456d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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?
|
|
@@ -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,
|
|
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
|
|
1032
|
-
#
|
|
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
|
|
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`,
|
data/lib/permittable/version.rb
CHANGED
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
|
-
|
|
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
|
|
1256
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
date: 2026-11-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|