permittable 0.12.0 → 0.13.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 +39 -0
- data/README.md +19 -7
- data/lib/permittable/audit.rb +16 -0
- data/lib/permittable/authored_values.rb +37 -1
- data/lib/permittable/column_guard.rb +33 -0
- data/lib/permittable/open_api.rb +272 -26
- data/lib/permittable/tasks/audit.rake +3 -5
- data/lib/permittable/tasks/generate.rake +1 -4
- data/lib/permittable/tasks/openapi.rake +1 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +66 -8
- 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: bbb9461a44e4918ca7a982fc42ad55e7c2c6a36b231ea9560eb298c85fea259c
|
|
4
|
+
data.tar.gz: 324137849cfc36dde9a3ff2764aeef2ba02beec9e6487bd70b4990b0ee3b95e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f612dc7e5b04cf543c34b4b9ff4810e0620f80a8dfcc6cbed92d2969eab40a4396b3472d9ac2f8b4a37bc1b1a8d92ddc166312edba7de78508de2c4435dcba31
|
|
7
|
+
data.tar.gz: 7dd8145f4968a76cd10332a17f74a30531a4c4d549becd967b80c5f2a7f4ad41b54751209abf94b9386aa6a0aaa939d3be8f1eb1a3dd1b309f05bd39c5d212d6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.13.1 (2026-11-04)
|
|
4
|
+
<!-- title: contracts that cannot be exported are refused at class load, a Metal catch-all and a date-parts example export correctly, and a pre-1582 date reads as the day it names -->
|
|
5
|
+
|
|
6
|
+
Six fixes, one documentation change and a development dependency bump, no new API. A `:datetime` `default:` or `example:` finer than a nanosecond and a `:json` `default:` or `example:` holding NaN or Infinity are now refused at class load, instead of loading and then breaking the export. The OpenAPI export of a catch-all contract on an `ActionController::Metal` controller no longer documents the framework's own methods as operations, and an array `example:` written with date parts is exported as the date it spells. A midnight-UTC `Time` or `DateTime` sent to a `:date` before 1582 is read as the day it names. A missing-column error for an ignored column no longer suggests a migration, and the README now says that a catch-all `rescue_from` declared after the include wins.
|
|
7
|
+
|
|
8
|
+
Patch rather than minor: nothing that works today stops working. Every entry is a crash or a wrong value on input that was already mishandled, an export correction that makes the document agree with what the server does, a clearer error message, documentation, or development tooling. Four things are visible from outside and worth knowing before you upgrade. A contract now fails at class load, naming the field, in three cases: its `:datetime` `default:` or `example:` is finer than a nanosecond (#159), which the export published as a different instant; its `:json` `default:` or `example:` holds NaN or Infinity (#163), which made the export raise; or its `:date` `in:` member is a `Time` on one of the days 1582-10-05 to 1582-10-14 (#164), which was read silently as 1582-09-30. The OpenAPI export of a Metal catch-all loses its bogus framework operations (#161). An array `example:` written with date parts is now exported correctly (#162).
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **An ignored column that is in the table was reported as missing, with a migration hint that would add it twice.** `ColumnGuard` checked a field against `column_names`, which leaves out `ignored_columns`, so `optional :legacy, :integer` on a model with `self.ignored_columns = %w[legacy]` raised "does not exist in the database" and suggested `bin/rails generate migration`. The guard still refuses the field, since assigning an ignored column raises `ActiveModel::UnknownAttributeError`, but the message now says the model ignores the column and suggests removing it from `ignored_columns` or declaring the field `virtual: true`. When the column was dropped and its name was left in `ignored_columns`, the message says the table has no such column and gives no migration. (#157)
|
|
12
|
+
- **A `:datetime` `default:` or `example:` finer than a nanosecond loaded, and the export published a different instant.** 0.12.0 refused such an `in:` member (#106), but `Time.at(Rational(1, 3)).utc` as a `default:` was exported as `.333333333Z`, so a client that sent the published default sent a different instant. A `default:` or `example:` finer than a nanosecond now raises `ArgumentError` at class load, naming the field, at any depth: a scalar, an `of: :datetime` array, an array of hashes and a sub-field. A String with ten or more fractional digits is refused too, unlike an `in:` String member, which is published as written. A nanosecond-exact value is still accepted. (#159)
|
|
13
|
+
- **The OpenAPI export documented about 36 framework methods of a Metal controller as operations.** A catch-all `permit_params` on an `ActionController::Metal` controller expanded from `action_methods`, which for a Metal subclass counts every public method of an included module, so `render`, `head`, `_prefixes`, `view_paths` and the rest were exported with operationIds such as `pings_render`. The catch-all now lists only the controller's own actions, as it does on an `ActionController::Base` controller. (#161)
|
|
14
|
+
- **An array `example:` written with date parts was exported as `[{}]`.** `array :items, example: [{ "on(1i)" => "2026", "on(2i)" => "10", "on(3i)" => "17" }]` was published as `[[{}]]`, though a request with the same parts reads as `2026-10-17`, and the server would fill in the sub-field's default for the published payload. The example is now judged on the hash with the date parts assembled, and is exported as `[{"on" => "2026-10-17"}]`. This covers `:date` and `:datetime` parts, in arrays and in a nested block inside an array. Requests are unchanged. (#162)
|
|
15
|
+
- **A `:json` `default:` or `example:` holding NaN or Infinity loaded, and the export raised.** `optional :meta, :json, default: { "score" => Float::NAN }` loaded, and `JSON.generate(json_schema)` then raised `JSON::GeneratorError`. No JSON request can carry NaN, so the value is one no client can send. A non-finite Float at any depth of a `:json` default or example, including a `:json` sub-field and a `:json` value inside an array, now raises `ArgumentError` at class load, naming the field and the path, such as `x[1].y`. Requests are unchanged. (#163)
|
|
16
|
+
- **A midnight-UTC `Time` sent to a `:date` before 1582 was read as the wrong day.** `Time.utc(1500, 1, 1)` became 1499-12-23, because `Time#to_date` labels days before the reform in the Julian calendar, while the String `"1500-01-01"` is proleptic Gregorian. The same applied to a `DateTime` and a `TimeWithZone`, and to `in:` members and `default:` values, which never matched or were exported as a different day. The day is now built from the instant's own year, month and day, so a Time, a DateTime and the String `"1500-01-01T00:00:00Z"` all give 1500-01-01. (#164)
|
|
17
|
+
- **A pre-1582 `DateTime` was read wrongly without ActiveSupport.** With a plain `require "permittable"`, `DateTime.new(1500, 1, 1)` sent to a `:date` landed on 1500-01-10. It now reads the DateTime's own calendar labels and gives 1500-01-01, with or without ActiveSupport. The days 1582-10-05 to 1582-10-14 do not exist in the calendar a `:date` reads, so a Time or DateTime on one of them is `invalid_type`, and an `in:` member on one raises the normal class-load error naming the field. A `:datetime` still accepts those days. (#164)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
- **The README now says that a catch-all `rescue_from` declared after `include Permittable` wins.** `include Permittable` registers `rescue_from Permittable::InvalidParameters`, and Rails searches handlers from the last declared, so a `rescue_from StandardError` placed after the include answers an invalid request with its own response, often a 500, and the gem's 422 never runs. The README gives the two fixes: declare the catch-all before the include, or declare `rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters` after it. (#158)
|
|
21
|
+
|
|
22
|
+
### Development
|
|
23
|
+
- **Bump `sqlite3` 2.10.0 to 2.10.1 and `rubocop` (bundler-minor-patch group).** Development dependencies only. (#167)
|
|
24
|
+
|
|
25
|
+
## 0.13.0 (2026-10-24)
|
|
26
|
+
<!-- title: the audit and the OpenAPI export follow mounted engines and Metal controllers, and a path variable is documented as one -->
|
|
27
|
+
|
|
28
|
+
Two changes to what the audit and the OpenAPI export read, and one to how the export documents path variables. `permittable:audit`, `permittable:openapi` and `permittable:generate` now see controllers built directly on `ActionController::Metal`. The audit and the export follow routes mounted from a Rails engine, with the mount's path in front of the engine's. A rootless contract field that a route fills from the URL is documented as the path parameter it is.
|
|
29
|
+
|
|
30
|
+
Minor rather than patch: there is no runtime change, `OpenAPI.rails_routes` callers now get more descriptors and a descriptor's `route:` may be an Array, but the audit and the OpenAPI document now see more, so `permittable:audit[strict]` can newly fail in an app that has an unguarded Metal controller or an unguarded route in a mounted engine, and an exported document changes for any app with a mounted engine, a Metal controller or a route-filled field.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **`OpenAPI.rails_routes(app, engines: false)` reads the app's own route set alone.** `engines:` is a new keyword and defaults to `true`, which follows mounted engines (see the first entry under **Changed**). (#144)
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- **The audit and the OpenAPI export follow mounted Rails engines.** `OpenAPI.rails_routes` read only the app's own route set, so with `mount Billing::Engine => "/billing"` an unguarded `POST /billing/refunds` was never listed and `[strict]` exited 0, a covered `billing/invoices#create` was reported stale, and the export put billing operations under `x-permittable-controllers` instead of `paths`. `rails_routes(app, engines: true)` now follows each mounted engine, nested ones too, and puts the mount's path in front of the engine's, so a mount under `scope "(:locale)"`, at a dynamic `":tenant"`, or at `/` expands like any route. Routes reached through a mount carry `route: [mount, route]`, with one index per mount level (e.g. `[3, 0, 5]`), so `route:` may be an Array. Dispatch follows Rails: a mount ending in an optional segment lists no engine path Rails sends elsewhere, shadowing between app and engine routes follows Rails' single route order with each mount expanded in place, and a mount's `via:` drops the engine's other verbs. Rack mounts that are not engines are still skipped, a recursion guard stops cycles, and mounted gem engines appear in the audit's "outside the app root" section by default (while `Permittable.audit_ignore_outside_root` is on), without affecting `[strict]`. Callers that concatenate an app's and an engine's lists themselves should pass `engines: false`, which restores the old output. A parameter name used by both the mount and an engine route is not renamed: the path names it twice (`/t/{id}/{id}`) and the operation declares that one path parameter once. An engine route whose optional groups can all be absent (`post "(:locale(/:region))"`) is not documented as the mount's own path, which Rails answers with 404, and a lambda-constrained mount is still listed. (#144)
|
|
37
|
+
- **`permittable:audit`, `permittable:openapi` and `permittable:generate` read `ActionController::Metal` controllers.** Each task collected controllers from `ActionController::Base.descendants` and `ActionController::API.descendants` only, so a lean webhook controller built on `Metal` was invisible: its unguarded `POST /hooks` had no audit row and did not fail `[strict]`, and one that included Permittable had its contracts enforced at runtime but missing from the export. All three tasks now read `Audit.rails_controllers`, which returns every named `ActionController::Metal` descendant less the framework's own abstract `ActionController::Base` and `ActionController::API`. (#108)
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **A rootless field a route fills from the URL is documented as the path parameter.** Rails merges the router's path parameters over the request's, so a rootless `required :id, :integer, in: 1..1000` on `resources :widgets, only: :update` is read from the URL, but the export gave the path parameter `{"type":"string"}` and put `id` in the request body's `required` list. The field now becomes the path parameter's schema, which stays required, and leaves the body's `properties` and `required`. Only a rootless contract does this, only for a scalar field, and only for a name the route's own path templates; an array, nested or `:json` field named like a variable stays in the body. With `unknown: :error`, every name the route templates stays in the body as an unconstrained, never-required property, so `{"id":7,"name":"x"}` is not documented as invalid. An action whose every field moved has no request body. A moved field loses `default:` and `nullable:`. A route that shares another's path pairs its variable names with the placed ones by position. (#136)
|
|
41
|
+
|
|
3
42
|
## 0.12.0 (2026-10-21)
|
|
4
43
|
<!-- title: stricter numbers, dates and class-load checks, and matchers that stop passing for the wrong reason -->
|
|
5
44
|
|
data/README.md
CHANGED
|
@@ -716,7 +716,7 @@ What you give up is the shape. What you keep:
|
|
|
716
716
|
| `max_depth:` | Caps **container nesting**, counting arrays as a level: `{"a": 1}` is 1, `{"a": {"b": 1}}` and `{"a": [1, 2]}` are 2, `{"a": [{"b": 1}]}` is 3. Violation code `depth` |
|
|
717
717
|
| `validate:` / `transform:` | See the whole hash, so any check you can write in Ruby still applies |
|
|
718
718
|
| `model:` | The field maps onto a column like a scalar does, so the [drift guard](#the-schema-drift-guard) still catches a dropped `metadata` column |
|
|
719
|
-
| `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash, and are checked against the field's own bounds at class load) |
|
|
719
|
+
| `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash holding no NaN or Infinity, and are checked against the field's own bounds at class load) |
|
|
720
720
|
|
|
721
721
|
Bounding it matters more than it looks: an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects. `max_depth:` and `length:` are how a contract says "opaque, but not unlimited" — which is strictly more than `permit(metadata: {})` can say.
|
|
722
722
|
|
|
@@ -773,6 +773,12 @@ Exported [OpenAPI](#exporting-openapi-docs-that-cannot-drift) tells the truth ab
|
|
|
773
773
|
|
|
774
774
|
Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope shown at the [top of this README](#permittable).
|
|
775
775
|
|
|
776
|
+
Rails searches `rescue_from` handlers from the last declared to the first, so a catch-all such as `rescue_from StandardError` declared *after* `include Permittable` answers an invalid request first, and the client gets that handler's response instead of the 422 or 400. Either declare the catch-all before the include, or re-declare the gem's handler after it:
|
|
777
|
+
|
|
778
|
+
```ruby
|
|
779
|
+
rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters
|
|
780
|
+
```
|
|
781
|
+
|
|
776
782
|
| Code | Raised when |
|
|
777
783
|
|---|---|
|
|
778
784
|
| `missing` | A required field is absent, or the `root:` key is absent (that one is a **400**) |
|
|
@@ -904,7 +910,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
|
|
|
904
910
|
|
|
905
911
|
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.
|
|
906
912
|
|
|
907
|
-
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. **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.
|
|
913
|
+
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 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. **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.
|
|
908
914
|
|
|
909
915
|
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.
|
|
910
916
|
|
|
@@ -1015,6 +1021,8 @@ If this parameter is not backed by a column, declare it with virtual: true.
|
|
|
1015
1021
|
|
|
1016
1022
|
The error carries a ready-to-paste migration command, typed from your own field declaration.
|
|
1017
1023
|
|
|
1024
|
+
A field naming a column the model lists in `ignored_columns` raises too, but says so (remove it from `ignored_columns`, or declare it `virtual: true`) rather than suggesting a migration.
|
|
1025
|
+
|
|
1018
1026
|
#### Checking types too (opt-in)
|
|
1019
1027
|
|
|
1020
1028
|
A dropped column fails the deploy; a **retyped** one doesn't, unless you ask:
|
|
@@ -1241,7 +1249,7 @@ No Rails required for the core: `Permittable::Generator.draft(model: User)`, `.f
|
|
|
1241
1249
|
|
|
1242
1250
|
A controller declaring `permit_params :create` looks adopted. If it also answers `PATCH`, that action is validating **nothing** — and until now nothing in the gem said so. [`permittable:generate`](#generating-draft-contracts-permittablegenerate) only notices controllers with no contract at all, and the [OpenAPI export](#exporting-openapi-docs-that-cannot-drift) documents what exists rather than what is missing.
|
|
1243
1251
|
|
|
1244
|
-
The audit crosses the contract registry with the **route set**, so a half-covered controller is as visible as an uncovered one:
|
|
1252
|
+
The audit crosses the contract registry with the **route set**, so a half-covered controller is as visible as an uncovered one. It reads every controller a route can reach: `ActionController::Base` and `::API` subclasses, and bare `ActionController::Metal` ones such as a lean webhook endpoint. The export and the generator read the same list.
|
|
1245
1253
|
|
|
1246
1254
|
```sh
|
|
1247
1255
|
bin/rails permittable:audit # the table plus a summary
|
|
@@ -1288,7 +1296,7 @@ A `config.exceptions_app = routes` setup (`match "/404", to: "errors#not_found",
|
|
|
1288
1296
|
|
|
1289
1297
|
Under `[strict]` the task aborts on these rows. Either route the catch-all GET-only, as above, or add `application#not_found` to the [ignore list](#ignoring-controllers-and-actions). Don't declare a contract on the catch-all to silence the gate. Under monitor mode it validates eagerly, so a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also gain a fake `/{path}` endpoint.
|
|
1290
1298
|
|
|
1291
|
-
The table lists a row for every verb on every path; the summary counts routes. A route with an optional segment, such as anything under `scope "(:locale)"`, lists each path it expands to (`/users` and `/{locale}/users`), but it is one route, so one unguarded `POST` counts once and the summary line says `N routed actions in M rows`. Rows are collapsed by controller, action, route index and verb; the index is a position within one `rails_routes` call, so
|
|
1299
|
+
The table lists a row for every verb on every path; the summary counts routes. A route with an optional segment, such as anything under `scope "(:locale)"`, lists each path it expands to (`/users` and `/{locale}/users`), but it is one route, so one unguarded `POST` counts once and the summary line says `N routed actions in M rows`. Rows are collapsed by controller, action, route index and verb; the index is a position within one `rails_routes` call (a route reached through a mounted engine is `[mount, route]`; see below), so a caller that concatenates route lists itself should pass `engines: false` for each, and audit them separately or give them distinct `route:` values. Two separate routes to the same action (`post "/users"` and `post "/admin/users"`) count as two, because each one is a way in.
|
|
1292
1300
|
|
|
1293
1301
|
Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails. It takes `ignore:` and `root:` too, which default to `Permittable.audit_ignore` and `Rails.root` (see below).
|
|
1294
1302
|
|
|
@@ -1312,7 +1320,9 @@ An entry is a controller path the way Rails routes it (the controller half of `b
|
|
|
1312
1320
|
|
|
1313
1321
|
Assign the whole list. It is frozen, so add to it with `Permittable.audit_ignore += ["webhooks#receive"]`, which is validated too.
|
|
1314
1322
|
|
|
1315
|
-
**
|
|
1323
|
+
**Mounted engines are part of the app.** `mount Billing::Engine => "/billing"` makes every route the engine draws a URL of the app, so `rails_routes` follows each mounted Rails engine (nested ones too) and puts the mount's path in front of the engine's: an unguarded `POST /billing/refunds` fails `[strict]`, and a covered action in the engine is not reported as stale. A mount at a dynamic `at: ":tenant"`, or under `scope "(:locale)"` at `/blog`, joins like any other path. A mount whose path ends in an optional segment (`scope "(:locale)"` over a mount at `/`, or `at: "/m(/:slug)"`) lists the engine's routes with the segment only, as Rails does: it matches the mount's own path first, the segment present before absent, so `/posts` is the locale `posts` asking for the engine's root, and the engine's `/posts` is reached as `/{locale}/posts`. A constrained segment (`locale: /en|fr/`) keeps both. A mount's `via:` limits the verbs: `mount Shop::Engine, at: "/g", via: :get` leaves out the engine's `POST` routes, which answer 404. An engine mounted twice is two routes to its actions. Routes are judged in Rails' order with the mount standing where it is drawn: an app route ahead of the mount answers before the engine is asked, an engine route that matches answers and hides an app route behind the mount, and a request the engine has no route for goes on to the next route. A constrained mount, like any constrained route, hides no route behind it. A Rack mount that is not an engine (Sidekiq::Web, a lambda) has no routes to read and is skipped. `Permittable::OpenAPI.rails_routes(app, engines: false)` reads the app's own route set alone. One limit: a parameter name used by both the mount and an engine route (`/{id}/.../{id}`) is not renamed, so the template names it twice (`/t/{id}/{id}`) and the operation declares that one path parameter once.
|
|
1324
|
+
|
|
1325
|
+
**Controllers outside `Rails.root` are ignored by default.** These are gems and engines installed as gems (an engine kept inside your app counts as yours): ActiveStorage, ActionMailbox, Devise, and Rails' own `rails/*` controllers. An app can't keep a contract in a gem's controller. The audit locates a controller by the file that defines its class (`Module#const_source_location`). A gem installed inside the app still counts as a gem; `bundle config path vendor/bundle` does this, and so do CI caches. A controller whose source can't be located is audited. To audit gem controllers like your own:
|
|
1316
1326
|
|
|
1317
1327
|
```ruby
|
|
1318
1328
|
Permittable.audit_ignore_outside_root = false
|
|
@@ -1457,7 +1467,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
|
1457
1467
|
|
|
1458
1468
|
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.
|
|
1459
1469
|
|
|
1460
|
-
**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
|
|
1470
|
+
**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.
|
|
1471
|
+
|
|
1472
|
+
**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.
|
|
1461
1473
|
|
|
1462
1474
|
**Paths are the URLs Rails dispatches.** An optional segment expands into each path it stands for, read the way Rails matches it: present before absent, left to right, constraints included. So `get "x(/:a)(/new)"` documents no `/x/new`, because Rails reads that URL as `a = "new"`, unless a constraint on `:a` rejects `"new"`. Rails squeezes a run of slashes to one before it matches, so a variant an absent leading group leaves with two in a row is no URL: `get "(:locale)(/:theme)"` documents `/`, `/{locale}` and `/{locale}/{theme}`, never `//{theme}`, and `get "(:locale(/:region))"` documents no `/`, which Rails answers with a 404. OpenAPI forbids two paths that differ only in their variable names, and two routes can produce them: under `scope "(:locale)"` a root is `/{locale}` and a `get ":slug"` is `/{slug}`. Such paths are one path, spelled as the route Rails tries first spells it. A later route under another verb is placed there too, and carries its own spelling in `x-permittable-path`. One under the same verb is shadowed: Rails sends that URL to the earlier route, and reaches the later one only when the earlier route's constraints turn the request away. It is listed in the earlier operation's `x-permittable-shadows` instead of being emitted as a second path, and its operation lands in `x-permittable-controllers` if it has no other path. A variant of one route that a constraint keeps reachable is listed the same way: `x(/:a)(/:b)` with `a: /\d+/` sends `/x/foo` to `:b`, so `/x/{a}` lists `/x/{b}`.
|
|
1463
1475
|
|
|
@@ -1588,7 +1600,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
1588
1600
|
- `length:` that isn't a non-negative `Integer` or a `Range`, or a `Range` with a negative or fractional end (`..-1`, `-2..3`, `1.5..3.5`; a whole-number Float such as `1..10.0` is fine; leave an end off, or use `Float::INFINITY`, for no bound); an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
|
|
1589
1601
|
- An `in:` member the field's own type can't cast (`in: %w[1 two]` on an `:integer`, `nil` on a field that isn't `nullable:`, or a `Time` on a `:date` field that isn't exactly midnight UTC), or an `in:` `Range` whose endpoints a value of the field's type can't be compared with (`in: "1".."5"` on an `:integer`) — either would reject every request as `inclusion`
|
|
1590
1602
|
- A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`)
|
|
1591
|
-
- A `Time` `in:` member on a `:datetime` field finer than a nanosecond (`Time.at(1.1)`, a third of a second): a request's fraction is a finite decimal and the export prints nine digits, so no request could equal
|
|
1603
|
+
- A `Time` `in:` member, `default:` or `example:` on a `:datetime` field finer than a nanosecond (`Time.at(1.1)`, a third of a second): a request's fraction is a finite decimal and the export prints nine digits, so no request could equal the member, and a published default or example would be a different instant
|
|
1592
1604
|
- `validate:` or `transform:` that isn't callable
|
|
1593
1605
|
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept
|
|
1594
1606
|
- A `default: nil` or `example: nil` on a field that isn't `nullable:`
|
data/lib/permittable/audit.rb
CHANGED
|
@@ -142,6 +142,22 @@ module Permittable
|
|
|
142
142
|
end
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# Every controller class a Rails route can reach, for the rake tasks:
|
|
146
|
+
# ActionController::Metal and everything below it. The tasks used to
|
|
147
|
+
# collect ActionController::Base and ::API descendants only, so a bare
|
|
148
|
+
# Metal controller — the usual shape of a lean webhook endpoint, and a
|
|
149
|
+
# host this gem supports — had no audit row, never counted against
|
|
150
|
+
# [strict], and its contracts were missing from the export. Base and API
|
|
151
|
+
# themselves are Metal descendants too; they are the framework's abstract
|
|
152
|
+
# bases, which no route names, and the old lists never held them.
|
|
153
|
+
# Anonymous classes cannot be routed to, so only named ones are kept.
|
|
154
|
+
def rails_controllers
|
|
155
|
+
return [] unless defined?(::ActionController::Metal)
|
|
156
|
+
|
|
157
|
+
frameworks = %w[ActionController::Base ActionController::API]
|
|
158
|
+
::ActionController::Metal.descendants.uniq.select { |controller| controller.name && !frameworks.include?(controller.name) }
|
|
159
|
+
end
|
|
160
|
+
|
|
145
161
|
# The root a controller's source must be inside to be audited:
|
|
146
162
|
# Rails.root, unless Permittable.audit_ignore_outside_root is off — or nil
|
|
147
163
|
# without Rails, where there is no app to be outside of.
|
|
@@ -103,6 +103,16 @@ module Permittable
|
|
|
103
103
|
found
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# An authored :datetime Time must survive the export's nine digits, at
|
|
107
|
+
# any depth, as a scalar `default:` or `example:` must (see Coercion).
|
|
108
|
+
def permittable_check_scalar(field, value)
|
|
109
|
+
Coercion.refuse_finer_than_nanosecond(field[:type], super)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def permittable_cast_element(field, element)
|
|
113
|
+
Coercion.refuse_finer_than_nanosecond(field[:of], super)
|
|
114
|
+
end
|
|
115
|
+
|
|
106
116
|
# Nor does it run an array-level validate:, which expects the transformed
|
|
107
117
|
# sub-fields a request hands it. The validating read has already run it
|
|
108
118
|
# on exactly those, so the stored read, which would hand it the
|
|
@@ -114,11 +124,13 @@ module Permittable
|
|
|
114
124
|
# What a client sends has no defaults filled in: an omitted sub-field
|
|
115
125
|
# stays omitted, at any depth, so the published example does not show a
|
|
116
126
|
# value (`cents: 0`) that no client sends. The walker fills them in; drop
|
|
117
|
-
# exactly what it filled, by the walker's own absence rule
|
|
127
|
+
# exactly what it filled, by the walker's own absence rule, judged on the
|
|
128
|
+
# hash the walker walked: one with the date parts read into their field.
|
|
118
129
|
def permittable_check_hash(fields, hash, **)
|
|
119
130
|
result = super
|
|
120
131
|
return result unless @as_sent
|
|
121
132
|
|
|
133
|
+
hash = permittable_read_date_parts(fields, hash)
|
|
122
134
|
fields.each do |field|
|
|
123
135
|
key = field[:name].to_s
|
|
124
136
|
next unless field.key?(:default)
|
|
@@ -130,6 +142,30 @@ module Permittable
|
|
|
130
142
|
result
|
|
131
143
|
end
|
|
132
144
|
|
|
145
|
+
# An authored hash is exported as a schema `default`/`example`, and JSON
|
|
146
|
+
# has no NaN or Infinity: JSON.generate raises on them when the document
|
|
147
|
+
# is written, long after the class loaded. A request cannot carry one
|
|
148
|
+
# (JSON has none; a form sends Strings), so only the authored value is
|
|
149
|
+
# held to it, here, and not permittable_check_json for every request.
|
|
150
|
+
def permittable_check_json(field, value)
|
|
151
|
+
status, read = super
|
|
152
|
+
return [status, read] unless status == :ok
|
|
153
|
+
|
|
154
|
+
at = non_finite_path(value, "")
|
|
155
|
+
return [:error, "holds NaN or Infinity at #{at}, which JSON cannot carry"] if at
|
|
156
|
+
|
|
157
|
+
[status, read]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Where the first NaN or infinite Float sits, as `a.b[1].c`, or nil.
|
|
161
|
+
def non_finite_path(value, path)
|
|
162
|
+
case value
|
|
163
|
+
when Float then path unless value.finite?
|
|
164
|
+
when Hash then value.each_pair.lazy.filter_map { |k, v| non_finite_path(v, path.empty? ? k.to_s : "#{path}.#{k}") }.first
|
|
165
|
+
when Array then value.each_with_index.lazy.filter_map { |v, i| non_finite_path(v, "#{path}[#{i}]") }.first
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
133
169
|
def permittable_violation(_field, param, code)
|
|
134
170
|
{ param: param, code: code.to_s }
|
|
135
171
|
end
|
|
@@ -49,6 +49,8 @@ module Permittable
|
|
|
49
49
|
|
|
50
50
|
fields.flatten.compact.each do |field|
|
|
51
51
|
unless klass.column_names.include?(field.to_s)
|
|
52
|
+
raise ArgumentError, ignored_column_message(label, klass, field) if ignored_column?(klass, field)
|
|
53
|
+
|
|
52
54
|
raise ArgumentError,
|
|
53
55
|
"#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
|
|
54
56
|
"#{column_migration_hint(klass, field, types)}"
|
|
@@ -204,6 +206,37 @@ module Permittable
|
|
|
204
206
|
klass.name || "Model"
|
|
205
207
|
end
|
|
206
208
|
|
|
209
|
+
# `klass.column_names` leaves out `self.ignored_columns`, so an ignored
|
|
210
|
+
# column that IS in the database reads as missing. Raising stays right
|
|
211
|
+
# (`Model.new(field: ...)` raises UnknownAttributeError), but the
|
|
212
|
+
# migration the missing-column message teaches would duplicate it.
|
|
213
|
+
def ignored_column?(klass, field)
|
|
214
|
+
klass.respond_to?(:ignored_columns) && klass.ignored_columns.map(&:to_s).include?(field.to_s)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The column may have been dropped since it was ignored, with its name
|
|
218
|
+
# left in ignored_columns; the table itself says which. That case needs
|
|
219
|
+
# different advice: removing the name would only lead to a migration
|
|
220
|
+
# that re-adds a column the app no longer wants.
|
|
221
|
+
def ignored_column_message(label, klass, field)
|
|
222
|
+
subject = "#{label}: '#{field}' is in #{model_expr(klass)}'s ignored_columns"
|
|
223
|
+
if column_in_table?(klass, field)
|
|
224
|
+
"#{subject} (table: #{klass.table_name}), so the model ignores that column " \
|
|
225
|
+
"and assigning it raises ActiveModel::UnknownAttributeError. " \
|
|
226
|
+
"Remove it from ignored_columns, or declare the field virtual: true if the app handles it itself."
|
|
227
|
+
else
|
|
228
|
+
"#{subject} but the table has no such column (table: #{klass.table_name}). " \
|
|
229
|
+
"Remove it from ignored_columns and the field from the contract, " \
|
|
230
|
+
"or declare the field virtual: true if the app handles it itself."
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Unlike `column_names`, the connection's own column list includes the
|
|
235
|
+
# columns the model ignores.
|
|
236
|
+
def column_in_table?(klass, field)
|
|
237
|
+
klass.connection_pool.with_connection { |connection| connection.column_exists?(klass.table_name, field) }
|
|
238
|
+
end
|
|
239
|
+
|
|
207
240
|
def column_migration_hint(klass, field, types)
|
|
208
241
|
type = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
209
242
|
column = [field, type].compact.join(":")
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -91,6 +91,54 @@ module Permittable
|
|
|
91
91
|
@concern_methods ||= Permittable.public_instance_methods(false).map(&:to_s).freeze
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
# Gems whose public methods are the framework's own: the ones a controller
|
|
95
|
+
# gets from the modules Rails mixes in (Rendering, Head, Callbacks, ViewPaths,
|
|
96
|
+
# Rescuable, ...) and from the class attributes those define on it.
|
|
97
|
+
FRAMEWORK_GEMS = %w[actionpack actionview activesupport activemodel railties].freeze
|
|
98
|
+
|
|
99
|
+
# Rails reads a controller's internal methods off its nearest abstract
|
|
100
|
+
# class: ActionController::Base includes all of those modules, so they are
|
|
101
|
+
# internal on a Base or API controller, but ActionController::Metal includes
|
|
102
|
+
# none, so on one built directly on it every public method of an included
|
|
103
|
+
# module is an action_methods entry. A method is the framework's when it
|
|
104
|
+
# is defined in one of its gems, by whichever module or class_attribute
|
|
105
|
+
# put it on the controller; a method the app defines, in the class or in a
|
|
106
|
+
# concern of its own, stays an action. So does one a superclass declares in
|
|
107
|
+
# its own file, even a framework controller's (Rails::HealthController#show):
|
|
108
|
+
# a method defined elsewhere in a gem, such as the class_attribute readers
|
|
109
|
+
# an app's parent class gets from the modules it includes, is not one.
|
|
110
|
+
def framework_method?(controller, action)
|
|
111
|
+
return false unless controller.respond_to?(:instance_method)
|
|
112
|
+
|
|
113
|
+
method = controller.instance_method(action)
|
|
114
|
+
file = method.source_location&.first
|
|
115
|
+
return false if file.nil? || framework_roots.none? { |root| file.start_with?(root) }
|
|
116
|
+
|
|
117
|
+
!declared_by_superclass?(method, controller, file)
|
|
118
|
+
rescue NameError
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Whether `method` is written in the file that defines its owner, a class
|
|
123
|
+
# other than the controller itself. An anonymous owner has no such file.
|
|
124
|
+
def declared_by_superclass?(method, controller, file)
|
|
125
|
+
owner = method.owner
|
|
126
|
+
owner.is_a?(Class) && owner != controller && !owner.name.nil? &&
|
|
127
|
+
Object.const_source_location(owner.name)&.first == file
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Resolved lazily, like concern_methods: the gems load after this file.
|
|
131
|
+
# Real paths, which is what source_location reports: a gem's own path is
|
|
132
|
+
# only expanded, so one reached through a symlink would never match.
|
|
133
|
+
def framework_roots
|
|
134
|
+
@framework_roots ||= FRAMEWORK_GEMS.filter_map do |name|
|
|
135
|
+
path = Gem.loaded_specs[name]&.full_gem_path or next
|
|
136
|
+
File.join(File.realpath(path), "")
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
File.join(path, "")
|
|
139
|
+
end.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
94
142
|
# Shared `components` for any document referencing Permittable responses.
|
|
95
143
|
#
|
|
96
144
|
# Unlike a rule's monitor mode — which the exporter reads only from the
|
|
@@ -176,7 +224,8 @@ module Permittable
|
|
|
176
224
|
return explicit unless contracts.any? { |rule| rule[:actions].empty? }
|
|
177
225
|
return explicit + ["*"] unless controller.respond_to?(:action_methods)
|
|
178
226
|
|
|
179
|
-
|
|
227
|
+
actions = controller.action_methods.map(&:to_s).sort - explicit - concern_methods
|
|
228
|
+
explicit + actions.reject { |action| framework_method?(controller, action) }
|
|
180
229
|
end
|
|
181
230
|
|
|
182
231
|
def operation_for(controller, action)
|
|
@@ -373,7 +422,7 @@ module Permittable
|
|
|
373
422
|
else
|
|
374
423
|
free.each do |path, verb, template|
|
|
375
424
|
holder = (paths[path] ||= {})
|
|
376
|
-
placed = with_path_parameters(operation, path)
|
|
425
|
+
placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path)
|
|
377
426
|
holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
|
|
378
427
|
slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
|
|
379
428
|
end
|
|
@@ -488,23 +537,93 @@ module Permittable
|
|
|
488
537
|
# OpenAPI 3.1 requires every variable in a path template to be declared as
|
|
489
538
|
# a path parameter — a document templating {id} without declaring it is
|
|
490
539
|
# invalid, which every member route produced. The route set does not say
|
|
491
|
-
# what an :id is
|
|
492
|
-
# a
|
|
493
|
-
|
|
494
|
-
|
|
540
|
+
# what an :id is, so a variable is a string unless the contract says
|
|
541
|
+
# otherwise: a rootless contract field named like one is that parameter's
|
|
542
|
+
# schema (path_fields), and leaves the body schema.
|
|
543
|
+
#
|
|
544
|
+
# `own` is the route's own spelling of the template, which differs
|
|
545
|
+
# from `path` when collapse_shapes placed the route under another
|
|
546
|
+
# route's spelling: the same shape, so the names pair up by position.
|
|
547
|
+
def with_path_parameters(operation, path, rule, own = path)
|
|
548
|
+
# A name the path templates twice (a mount and an engine route both
|
|
549
|
+
# name `:id`) is declared once: OpenAPI forbids two parameters with
|
|
550
|
+
# one name and location.
|
|
551
|
+
variables = path.scan(/\{(\w+)\}/).flatten.uniq
|
|
495
552
|
return operation if variables.empty?
|
|
496
553
|
|
|
554
|
+
own_names = own.scan(/\{(\w+)\}/).flatten
|
|
555
|
+
fields = path_fields(rule, path.scan(/\{(\w+)\}/).flatten.zip(own_names).uniq(&:first))
|
|
497
556
|
parameters = variables.map do |name|
|
|
498
|
-
|
|
557
|
+
schema = fields[name] ? path_schema(fields[name]) : { "type" => "string" }
|
|
558
|
+
{ "name" => name, "in" => "path", "required" => true, "schema" => schema }
|
|
499
559
|
end
|
|
500
560
|
# Inserted ahead of requestBody, where a reader of the document expects
|
|
501
561
|
# it; emission stays deterministic either way.
|
|
502
562
|
operation.each_with_object({}) do |(key, value), out|
|
|
503
|
-
|
|
504
|
-
|
|
563
|
+
if key == "requestBody"
|
|
564
|
+
out["parameters"] = parameters
|
|
565
|
+
value = body_without(rule, fields.values, own_names)
|
|
566
|
+
end
|
|
567
|
+
out[key] = value unless value.nil?
|
|
505
568
|
end
|
|
506
569
|
end
|
|
507
570
|
|
|
571
|
+
# { path variable => the contract's field for it }. Rails merges the
|
|
572
|
+
# route's path parameters over the request's, so a field the route fills
|
|
573
|
+
# from the URL is read from the URL, never from the body: `id` in the
|
|
574
|
+
# body is overwritten, and a request that leaves it out is fine. A rooted
|
|
575
|
+
# contract reads its fields from inside the root, which a path parameter
|
|
576
|
+
# never reaches, so only a rootless one has such fields, and only at its
|
|
577
|
+
# top level. `names` pairs each variable of the placed path with the
|
|
578
|
+
# route's own name for it.
|
|
579
|
+
#
|
|
580
|
+
# Only a scalar field moves. A URL segment is one string, so an array or
|
|
581
|
+
# a hash can never be what it carries (`POST /defs/1,2` against
|
|
582
|
+
# `array :id` is a 422). Such a field stays in the body, where a client
|
|
583
|
+
# can at least say what it means.
|
|
584
|
+
def path_fields(rule, names)
|
|
585
|
+
return {} if rule[:root]
|
|
586
|
+
|
|
587
|
+
names.filter_map do |variable, own|
|
|
588
|
+
field = rule[:fields].find { |f| f[:name].to_s == own }
|
|
589
|
+
[variable, field] if field && field[:kind] == :scalar
|
|
590
|
+
end.to_h
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# A moved field's schema as the path parameter documents it. The URL
|
|
594
|
+
# always supplies the segment, so a `default:` never applies and the
|
|
595
|
+
# value is never null: `nullable:` would only add "null" to the type.
|
|
596
|
+
def path_schema(field)
|
|
597
|
+
JsonSchema.field(field.except(:default, :nullable))
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# The requestBody for `rule` without the fields a path parameter took
|
|
601
|
+
# over; nil, to leave it out, when that was every field. Such a body
|
|
602
|
+
# would be an empty object. An action declared with no fields at all
|
|
603
|
+
# keeps the empty body it always had.
|
|
604
|
+
#
|
|
605
|
+
# `unknown: :error` forbids every key the contract does not declare, but
|
|
606
|
+
# the runtime exempts the route's path parameters from that check and
|
|
607
|
+
# then overwrites them with the URL's values: `PATCH /widgets/7` with
|
|
608
|
+
# {"id":7,"name":"x"} is a 200 whether or not the contract declares `id`.
|
|
609
|
+
# That is the payload a Rails form, or a client sending a record back,
|
|
610
|
+
# produces, so the schema must not forbid it. Each name the route
|
|
611
|
+
# templates therefore stays in the body as an unconstrained property,
|
|
612
|
+
# any value and never required, since the value is read from the URL and
|
|
613
|
+
# nothing in the body can make the request wrong. A rooted contract
|
|
614
|
+
# needs none of this: its wrapper object is permissive already.
|
|
615
|
+
def body_without(rule, moved, templated)
|
|
616
|
+
rest = rule[:fields] - moved
|
|
617
|
+
return if rest.empty? && !moved.empty?
|
|
618
|
+
|
|
619
|
+
body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
|
|
620
|
+
return body unless rule[:unknown] == :error && !rule[:root]
|
|
621
|
+
|
|
622
|
+
properties = body["content"]["application/json"]["schema"]["properties"]
|
|
623
|
+
templated.each { |name| properties[name] ||= {} }
|
|
624
|
+
body
|
|
625
|
+
end
|
|
626
|
+
|
|
508
627
|
# Drops every descriptor for a verb and concrete path that an earlier,
|
|
509
628
|
# unconstrained route already answers (rails_routes calls it last).
|
|
510
629
|
# Journey sorts the routes matching a request by precedence, their
|
|
@@ -533,10 +652,13 @@ module Permittable
|
|
|
533
652
|
#
|
|
534
653
|
# Descriptors arrive in route order (then verb, then optional variant),
|
|
535
654
|
# so the first one seen is the first route. Precedence is a property of
|
|
536
|
-
# ONE route
|
|
537
|
-
# not in the readers:
|
|
538
|
-
#
|
|
539
|
-
#
|
|
655
|
+
# ONE route table, which is why this runs once inside each rails_routes
|
|
656
|
+
# call and not in the readers: a list concatenated from an app's and an
|
|
657
|
+
# engine's must not let one shadow the other. An engine followed from its
|
|
658
|
+
# mount is part of that one table, expanded where the mount is drawn, so
|
|
659
|
+
# an app route ahead of the mount hides the engine's route for the same
|
|
660
|
+
# slot and an engine route hides a later app one (see rails_routes). An
|
|
661
|
+
# engine route is unconditional only when its mount is as well.
|
|
540
662
|
#
|
|
541
663
|
# Two more limits, both towards listing more. Only identical templates
|
|
542
664
|
# are compared, so templates that differ at all, even only in a
|
|
@@ -616,9 +738,77 @@ module Permittable
|
|
|
616
738
|
# A verb on a path that an earlier, unconstrained route already answers
|
|
617
739
|
# is left out: Rails never dispatches it to the later route (see
|
|
618
740
|
# drop_shadowed).
|
|
619
|
-
|
|
620
|
-
|
|
741
|
+
#
|
|
742
|
+
# A mounted Rails engine is part of the application: `mount Billing::Engine
|
|
743
|
+
# => "/billing"` makes every route the engine draws a URL of the app, so
|
|
744
|
+
# the audit's gate and the export must see them. With `engines: true`
|
|
745
|
+
# (the default) each such mount is followed into the engine's own route
|
|
746
|
+
# set, and the mount's path is put in front of every path found there,
|
|
747
|
+
# token by token, so a mount under `scope "(:locale)"`, one at a
|
|
748
|
+
# dynamic `at: ":tenant"` and one at "/" expand and join exactly as an
|
|
749
|
+
# app route would. A route reached through a mount carries its position
|
|
750
|
+
# as an Array, `[mount, route]` (`[3, 0, 5]` through an engine mounted in
|
|
751
|
+
# an engine), where an app route carries a bare Integer; so one engine
|
|
752
|
+
# mounted twice is two routes to its actions, as it is to a client. Only
|
|
753
|
+
# a Rails engine is followed: a Sidekiq::Web or a proc has no route set
|
|
754
|
+
# of ours to read and is skipped, as it always was. `engines: false`
|
|
755
|
+
# reads the one route set alone, for a caller that concatenates the lists
|
|
756
|
+
# of an app and its engines itself.
|
|
757
|
+
#
|
|
758
|
+
# Rails tries the routes in order with a mount standing where it is
|
|
759
|
+
# drawn, so the engine's routes are judged in that one order with the
|
|
760
|
+
# app's (see drop_shadowed): an app route ahead of the mount answers
|
|
761
|
+
# before the engine is asked, an engine route that matches answers and
|
|
762
|
+
# leaves an app route behind the mount unreached, and only a request the
|
|
763
|
+
# engine has no route for goes on (X-Cascade). A mount's `via:` limits
|
|
764
|
+
# the verbs it hands over: another verb is passed on, never answered by
|
|
765
|
+
# the engine.
|
|
766
|
+
#
|
|
767
|
+
# A parameter the mount and an engine route both name (`/{id}/.../{id}`)
|
|
768
|
+
# is not renamed: the template names it twice, the operation declares it
|
|
769
|
+
# once, and the README says so.
|
|
770
|
+
def rails_routes(app, engines: true)
|
|
771
|
+
drop_shadowed(collect_routes(app.routes, engines: engines, prefixes: [Prefix.new([], [])], parents: [],
|
|
772
|
+
visiting: [].freeze, reachable: true))
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# What a route set mounted at one variant of its mount's path is led by.
|
|
776
|
+
# `tokens` are the mount's path from the root (see join_paths).
|
|
777
|
+
# `shadowed_by` are the paths of the variants Rails tries first, which
|
|
778
|
+
# leave this one nothing that they accept: a mount at "/" under
|
|
779
|
+
# `scope "(:locale)"` is matched as `/{locale}` before it is matched as
|
|
780
|
+
# "/", so /posts is the locale "posts" asking for the engine's root, and
|
|
781
|
+
# the engine's own /posts is not reached through the second variant. The
|
|
782
|
+
# mount's path is matched as a whole, unanchored and with no way back
|
|
783
|
+
# into it once the engine has been asked, so a variant that fits the
|
|
784
|
+
# leading segments of a path takes it whether or not the engine has a
|
|
785
|
+
# route for the rest.
|
|
786
|
+
Prefix = Struct.new(:tokens, :shadowed_by)
|
|
787
|
+
|
|
788
|
+
# The descriptors of one route set (`set`, anything with `routes`), each
|
|
789
|
+
# path led by every one of `prefixes` (a Prefix for each variant of the
|
|
790
|
+
# mounts above it, one empty one at the top) and each `route:` led by
|
|
791
|
+
# `parents`. `reachable` is whether every mount above answers every
|
|
792
|
+
# request for its path (see unconditional?); a route behind one that
|
|
793
|
+
# does not may be passed over, so it hides nothing. `visiting` is the
|
|
794
|
+
# route sets above this one, so an engine that mounts itself, or an app
|
|
795
|
+
# and engine that mount each other, ends the walk instead of recursing
|
|
796
|
+
# for good. Rails would loop on such a request too; the audit must not
|
|
797
|
+
# hang on it.
|
|
798
|
+
#
|
|
799
|
+
# The descriptors still carry `unconditional:`; the caller runs
|
|
800
|
+
# drop_shadowed over the whole list, mounts expanded in place.
|
|
801
|
+
def collect_routes(set, engines:, prefixes:, parents:, visiting:, reachable:)
|
|
802
|
+
visiting += [set]
|
|
803
|
+
set.routes.each_with_index.flat_map do |route, index|
|
|
804
|
+
id = parents.empty? ? index : parents + [index]
|
|
621
805
|
requirements = route.requirements
|
|
806
|
+
if engines && (mounted = mounted_routes(route)) && visiting.none? { |above| above.equal?(mounted) }
|
|
807
|
+
found = collect_routes(mounted, engines: engines, prefixes: mount_prefixes(prefixes, route), parents: Array(id),
|
|
808
|
+
visiting: visiting, reachable: reachable && unconditional?(route))
|
|
809
|
+
verbs = route.verb.to_s.downcase.split("|")
|
|
810
|
+
next verbs.empty? ? found : found.select { |descriptor| verbs.include?(descriptor[:verb]) }
|
|
811
|
+
end
|
|
622
812
|
verb = route.verb.to_s
|
|
623
813
|
next [] if requirements[:controller].nil? || requirements[:action].nil?
|
|
624
814
|
|
|
@@ -627,9 +817,9 @@ module Permittable
|
|
|
627
817
|
# audit entirely. Expand it into the verbs it actually answers.
|
|
628
818
|
verb = ALL_VERBS if verb.empty?
|
|
629
819
|
|
|
630
|
-
variants = optional_variants(route.path.spec, requirements)
|
|
820
|
+
variants = optional_variants(route.path.spec, requirements, prefixes)
|
|
631
821
|
# Read once per route; drop_shadowed reads it, then strips it.
|
|
632
|
-
unconditional = unconditional?(route)
|
|
822
|
+
unconditional = reachable && unconditional?(route)
|
|
633
823
|
# One route can answer several verbs (`match via: [:patch, :put]`, and
|
|
634
824
|
# the PATCH|PUT pair resources generates); documenting only the first
|
|
635
825
|
# dropped the others from the export entirely. Verb by verb, then
|
|
@@ -638,12 +828,47 @@ module Permittable
|
|
|
638
828
|
verb.split("|").flat_map do |single|
|
|
639
829
|
variants.map do |path, shadows|
|
|
640
830
|
descriptor = { controller: requirements[:controller], action: requirements[:action],
|
|
641
|
-
verb: single.downcase, path: path, route:
|
|
831
|
+
verb: single.downcase, path: path, route: id, unconditional: unconditional }
|
|
642
832
|
shadows.empty? ? descriptor : descriptor.merge(shadows: shadows)
|
|
643
833
|
end
|
|
644
834
|
end
|
|
645
835
|
end
|
|
646
|
-
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# The Prefix of each variant of a mount's path under each of `prefixes`,
|
|
839
|
+
# outermost mount first, and within a mount present before absent, which
|
|
840
|
+
# is the order Rails' regexp tries them in. A variant is shadowed by every
|
|
841
|
+
# variant ahead of it: those of the same mount, and (through the
|
|
842
|
+
# `shadowed_by` it inherits) those of a mount above, judged where each
|
|
843
|
+
# was matched.
|
|
844
|
+
def mount_prefixes(prefixes, route)
|
|
845
|
+
own = variant_tokens(route.path.spec, route.requirements)
|
|
846
|
+
prefixes.flat_map do |above|
|
|
847
|
+
own.each_with_index.map do |tokens, index|
|
|
848
|
+
Prefix.new(join_paths(above.tokens, tokens),
|
|
849
|
+
above.shadowed_by + own.take(index).map { |earlier| join_paths(above.tokens, earlier) })
|
|
850
|
+
end
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# The route set a mount route hands requests to, or nil for any other
|
|
855
|
+
# route. Rails itself asks `route.app.engine?` (RoutesInspector does)
|
|
856
|
+
# and reads the set off `rack_app`. A route that cannot answer, such as a
|
|
857
|
+
# hand-built duck, is not a mount.
|
|
858
|
+
def mounted_routes(route)
|
|
859
|
+
return unless route.respond_to?(:app) && route.app.respond_to?(:engine?) && route.app.engine?
|
|
860
|
+
|
|
861
|
+
engine = route.app.rack_app
|
|
862
|
+
engine.routes if engine.respond_to?(:routes)
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# `outer` then `inner`, as one path: the slash the mount ends in and the
|
|
866
|
+
# one the inner path starts with are one slash, and a route to the
|
|
867
|
+
# engine's root ("/") adds nothing to the mount's own path.
|
|
868
|
+
def join_paths(outer, inner)
|
|
869
|
+
inner = inner.drop(1) if outer.last == "/" && inner.first == "/"
|
|
870
|
+
inner = [] if inner == ["/"] && !outer.empty?
|
|
871
|
+
join_literals(outer + inner)
|
|
647
872
|
end
|
|
648
873
|
|
|
649
874
|
# A route parameter as optional_variants reads it: a `:name` segment or a
|
|
@@ -704,13 +929,23 @@ module Permittable
|
|
|
704
929
|
# string: `to_s` prints an escaped parenthesis in a literal as a bare
|
|
705
930
|
# one, which a string scan then read as a group. A Journey-shaped stand-in
|
|
706
931
|
# may carry a String spec instead (string_variants).
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
932
|
+
#
|
|
933
|
+
# `prefixes` are the Prefix of each variant of the mounts the route sits
|
|
934
|
+
# under (one empty one, none, by default). Each is put in front of every
|
|
935
|
+
# variant, the mount's variants outermost, which is the order Rails'
|
|
936
|
+
# regexp tries them in; a path a mount variant ahead of its own takes
|
|
937
|
+
# first is left out (see Prefix), and the shadowing above then judges the
|
|
938
|
+
# rest.
|
|
939
|
+
def optional_variants(spec, requirements = {}, prefixes = [Prefix.new([], [])])
|
|
940
|
+
# The route's own variants are judged before a mount's path is put in
|
|
941
|
+
# front of them: an empty one (`(:locale(/:region))` with nothing
|
|
942
|
+
# present) is no request path, but joined onto a mount it would read as
|
|
943
|
+
# the mount's own path, which Rails does not hand to that route.
|
|
944
|
+
own = variant_tokens(spec, requirements).select { |tokens| request_path?(tokens) }
|
|
945
|
+
variants = prefixes.flat_map do |prefix|
|
|
946
|
+
own.map { |tokens| join_paths(prefix.tokens, tokens) }
|
|
947
|
+
.reject { |joined| prefix.shadowed_by.any? { |earlier| shadowed?(earlier, joined.first(earlier.length)) } }
|
|
948
|
+
end
|
|
714
949
|
# No group, no choice: nothing for one variant to shadow.
|
|
715
950
|
return { render(variants.first) => [] } if variants.one?
|
|
716
951
|
|
|
@@ -732,6 +967,17 @@ module Permittable
|
|
|
732
967
|
kept.to_a.reverse.to_h
|
|
733
968
|
end
|
|
734
969
|
|
|
970
|
+
# The variants of a path spec, each a list of tokens with adjacent text
|
|
971
|
+
# joined (see join_literals), before any is dropped as shadowed.
|
|
972
|
+
def variant_tokens(spec, requirements)
|
|
973
|
+
variants = if spec.respond_to?(:type)
|
|
974
|
+
tree_variants(spec, requirements)
|
|
975
|
+
else
|
|
976
|
+
string_variants(spec.to_s.sub(FORMAT_GROUP, ""), requirements).first
|
|
977
|
+
end
|
|
978
|
+
variants.map { |tokens| join_literals(tokens) }
|
|
979
|
+
end
|
|
980
|
+
|
|
735
981
|
# A Journey tree's variants, each a list of tokens: literal text and
|
|
736
982
|
# Param. A group yields its contents' variants, then the empty one —
|
|
737
983
|
# present before absent, as Rails' regexp tries them — except the
|
|
@@ -11,12 +11,10 @@ namespace :permittable do
|
|
|
11
11
|
task :audit, [:strict] => :environment do |_t, task_args|
|
|
12
12
|
Rails.application.eager_load!
|
|
13
13
|
|
|
14
|
-
bases = []
|
|
15
|
-
bases << ActionController::Base if defined?(ActionController::Base)
|
|
16
|
-
bases << ActionController::API if defined?(ActionController::API)
|
|
17
14
|
# Every controller, not just the ones including Permittable — a controller
|
|
18
|
-
# that never included it is unguarded, which is exactly the finding.
|
|
19
|
-
|
|
15
|
+
# that never included it is unguarded, which is exactly the finding. Bare
|
|
16
|
+
# ActionController::Metal ones included; see Audit.rails_controllers.
|
|
17
|
+
controllers = Permittable::Audit.rails_controllers
|
|
20
18
|
routes = Permittable::OpenAPI.rails_routes(Rails.application)
|
|
21
19
|
|
|
22
20
|
entries = Permittable::Audit.entries(controllers: controllers, routes: routes)
|
|
@@ -12,10 +12,7 @@ namespace :permittable do
|
|
|
12
12
|
task :generate, [:controller] => :environment do |_t, task_args|
|
|
13
13
|
Rails.application.eager_load!
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
bases << ActionController::Base if defined?(ActionController::Base)
|
|
17
|
-
bases << ActionController::API if defined?(ActionController::API)
|
|
18
|
-
controllers = bases.flat_map(&:descendants).uniq.select(&:name)
|
|
15
|
+
controllers = Permittable::Audit.rails_controllers
|
|
19
16
|
|
|
20
17
|
# { controller => actions to leave out of its draft }. A named controller
|
|
21
18
|
# is drafted even if covered or ignored; the sweep skips both, and leaves
|
|
@@ -15,10 +15,7 @@ namespace :permittable do
|
|
|
15
15
|
task :openapi, [:output] => :environment do |_t, task_args|
|
|
16
16
|
Rails.application.eager_load!
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
bases << ActionController::Base if defined?(ActionController::Base)
|
|
20
|
-
bases << ActionController::API if defined?(ActionController::API)
|
|
21
|
-
controllers = bases.flat_map(&:descendants).uniq.select do |controller|
|
|
18
|
+
controllers = Permittable::Audit.rails_controllers.select do |controller|
|
|
22
19
|
controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any?
|
|
23
20
|
end
|
|
24
21
|
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -1046,9 +1046,31 @@ module Permittable
|
|
|
1046
1046
|
# with a time of day always got. Shared by cast_date and
|
|
1047
1047
|
# instant_as_date, so a value and an in: member cannot be read
|
|
1048
1048
|
# differently; and always a plain Date, never the DateTime it came as.
|
|
1049
|
+
#
|
|
1050
|
+
# Built from the UTC components, as a String is (cast_date), not with
|
|
1051
|
+
# Time#to_date: that keeps the instant's day NUMBER and labels it in the
|
|
1052
|
+
# Julian calendar before the 1582 reform, so Time.utc(1500, 1, 1) came
|
|
1053
|
+
# out as 1499-12-23 while "1500-01-01" was 1500-01-01.
|
|
1054
|
+
#
|
|
1055
|
+
# A day the 1582 reform skipped (1582-10-05..14) is no Date, as the String
|
|
1056
|
+
# "1582-10-10" is none, so it is nil here and every caller gives its usual
|
|
1057
|
+
# error. :datetime is proleptic Gregorian and does accept those days; the
|
|
1058
|
+
# two differ there by design, :date being the String path's calendar.
|
|
1059
|
+
#
|
|
1060
|
+
# A DateTime is read by its own civil day, not through #to_time: stdlib's
|
|
1061
|
+
# converts by day number, so outside Rails (ActiveSupport converts by
|
|
1062
|
+
# component) a DateTime before 1582 landed 9 days late.
|
|
1049
1063
|
def date_of_instant(instant)
|
|
1050
|
-
|
|
1051
|
-
|
|
1064
|
+
if instant.is_a?(DateTime)
|
|
1065
|
+
utc = instant.new_offset(0)
|
|
1066
|
+
return unless utc.hour.zero? && utc.minute.zero? && utc.second.zero? && utc.sec_fraction.zero?
|
|
1067
|
+
else
|
|
1068
|
+
utc = instant.to_time.getutc
|
|
1069
|
+
return unless utc == utc.beginning_of_day
|
|
1070
|
+
end
|
|
1071
|
+
return unless Date.valid_date?(utc.year, utc.month, utc.day)
|
|
1072
|
+
|
|
1073
|
+
Date.new(utc.year, utc.month, utc.day)
|
|
1052
1074
|
end
|
|
1053
1075
|
|
|
1054
1076
|
# A zoneless String parses as UTC regardless of the host timezone
|
|
@@ -1241,11 +1263,34 @@ module Permittable
|
|
|
1241
1263
|
return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
|
|
1242
1264
|
|
|
1243
1265
|
status, value = cast(type, member)
|
|
1244
|
-
return [status, value] unless status == :ok && type
|
|
1266
|
+
return [status, value] unless status == :ok && finer_than_nanosecond?(type, member, value)
|
|
1245
1267
|
|
|
1246
1268
|
[:error, "finer than a nanosecond, so it never equals a request; round it, e.g. with .round(9)"]
|
|
1247
1269
|
end
|
|
1248
1270
|
|
|
1271
|
+
# Whether `raw`, authored as a Time-like `in:` member of a :datetime
|
|
1272
|
+
# field and cast to `cast_value`, holds more than the nine digits the
|
|
1273
|
+
# export prints. A String `in:` member is exempt: it is cast exactly as a
|
|
1274
|
+
# request sending it is, and published as written (see
|
|
1275
|
+
# published_in_member), so whatever precision it spells is reachable.
|
|
1276
|
+
def finer_than_nanosecond?(type, raw, cast_value)
|
|
1277
|
+
type == :datetime && !raw.is_a?(String) && !whole_nanoseconds?(cast_value)
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
# An authored `default:` or `example:` is held to the same rule as an
|
|
1281
|
+
# `in:` member: the exported schema prints it to nine digits, so a value
|
|
1282
|
+
# finer than that was published as a different instant than the action
|
|
1283
|
+
# got for an omitted field, and a client sending the published default
|
|
1284
|
+
# was not sending the default. Unlike an `in:` member, a String is NOT
|
|
1285
|
+
# exempt here: a default or example is stored as the cast Time and
|
|
1286
|
+
# exported from that, never as written, so "...00.1234567891Z" printed
|
|
1287
|
+
# as .123456789Z. `outcome` is the cast's [status, value].
|
|
1288
|
+
def refuse_finer_than_nanosecond(type, outcome)
|
|
1289
|
+
return outcome unless outcome.first == :ok && type == :datetime && !whole_nanoseconds?(outcome.last)
|
|
1290
|
+
|
|
1291
|
+
[:error, "finer than a nanosecond, so the export publishes a different instant; round it, e.g. with .round(9)"]
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1249
1294
|
# A request's fraction of a second is a finite decimal, and the export
|
|
1250
1295
|
# prints a member to at most nine digits, so a member that is not a whole
|
|
1251
1296
|
# number of nanoseconds — Time.at(1.1) holds 1.10000000000000008881...,
|
|
@@ -1267,7 +1312,7 @@ module Permittable
|
|
|
1267
1312
|
# class-load error a reason where a request gets `invalid_type`.
|
|
1268
1313
|
def instant_as_date(member)
|
|
1269
1314
|
date = date_of_instant(member)
|
|
1270
|
-
date ? [:ok, date] : [:error, "not midnight UTC, so it never equals a date"]
|
|
1315
|
+
date ? [:ok, date] : [:error, "not midnight UTC of an existing day, so it never equals a date"]
|
|
1271
1316
|
end
|
|
1272
1317
|
|
|
1273
1318
|
# What an exported enum lists for one member: the cast value, re-encoded
|
|
@@ -1885,7 +1930,9 @@ module Permittable
|
|
|
1885
1930
|
# proc cannot rewrite the host's own literal.
|
|
1886
1931
|
authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
|
|
1887
1932
|
value = Coercion.apply_normalize(field[:normalize], authored)
|
|
1888
|
-
status, result = Coercion.
|
|
1933
|
+
status, result = Coercion.refuse_finer_than_nanosecond(
|
|
1934
|
+
field[:type], Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
|
|
1935
|
+
)
|
|
1889
1936
|
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
|
|
1890
1937
|
|
|
1891
1938
|
field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
|
|
@@ -2758,7 +2805,7 @@ module Permittable
|
|
|
2758
2805
|
case field[:kind]
|
|
2759
2806
|
when :scalar
|
|
2760
2807
|
# Already normalized by permittable_normalized, before the absence rule.
|
|
2761
|
-
permittable_check_whole(field,
|
|
2808
|
+
permittable_check_whole(field, permittable_check_scalar(field, value), full, result, violations: violations)
|
|
2762
2809
|
when :json
|
|
2763
2810
|
permittable_check_whole(field, permittable_check_json(field, value), full, result, violations: violations)
|
|
2764
2811
|
when :nested
|
|
@@ -2883,7 +2930,18 @@ module Permittable
|
|
|
2883
2930
|
field[:transform] ? field[:transform].call(value) : value
|
|
2884
2931
|
end
|
|
2885
2932
|
|
|
2886
|
-
#
|
|
2933
|
+
# More seams, for the same reader: how a scalar field's value, and an
|
|
2934
|
+
# array element, are cast. A request's are cast as they come; an authored
|
|
2935
|
+
# default: or example: is also held to the nine digits the export prints.
|
|
2936
|
+
def permittable_check_scalar(field, value)
|
|
2937
|
+
Coercion.check_scalar(field, value)
|
|
2938
|
+
end
|
|
2939
|
+
|
|
2940
|
+
def permittable_cast_element(field, element)
|
|
2941
|
+
Coercion.cast(field[:of], element)
|
|
2942
|
+
end
|
|
2943
|
+
|
|
2944
|
+
# A third seam, for the same reader: the array-level `validate:` to run.
|
|
2887
2945
|
def permittable_array_validator(field)
|
|
2888
2946
|
field[:validate]
|
|
2889
2947
|
end
|
|
@@ -2898,7 +2956,7 @@ module Permittable
|
|
|
2898
2956
|
path: path, unknown: unknown, top_level: false, violations: violations)
|
|
2899
2957
|
end
|
|
2900
2958
|
|
|
2901
|
-
status, out =
|
|
2959
|
+
status, out = permittable_cast_element(field, permittable_own(element))
|
|
2902
2960
|
return out if status == :ok
|
|
2903
2961
|
|
|
2904
2962
|
violations << permittable_violation(field, path, out)
|
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.13.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
|
+
date: 2026-11-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|