permittable 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 737a1777a6da7e8bcce3e969b82796279f0b5f1f51f3497abad696c4622f6951
4
- data.tar.gz: 8c01787cc7081db7bbf6f4f0ce5a1f337b32e4d801d96bd6bd1f82678092c42e
3
+ metadata.gz: 7dc4908fbfa3e6b5e58441e97f3a1f73415a779447b06fd0d42b4a6fdc4154e4
4
+ data.tar.gz: 776776cac3cfd9288af6f591c96288acabb82707002017964a1ecee6b18dd9aa
5
5
  SHA512:
6
- metadata.gz: 19320e84873a06b21eec376ddb236275ac7beaf217d29eed91e3dfab1e26c644c35538c6549ef57a73c6dd95b8bc9c5305aef8ed40075c3823f5d8a53dfd5404
7
- data.tar.gz: 99cf9b0eee28ab54f31388fe647280b4f372022dc1bfc02ff01a09ef70032d68e0fde76816a51699fc9127b85f5230cd6938c2f4899981f954ec58d3e87a8de2
6
+ metadata.gz: 7f045ce704da2e70e4a1a85a4b7b8cdd2f5099733619779655885fc820b1fa03b6f4d99a7461e492d01147c15cc545fa268e46b11f13bfbd640cb334d3b98ff8
7
+ data.tar.gz: c82a67ca14285e04c35b218cd363f8280f2876e09faec983ded0d4aa81e30626d60e3fd67211ee0588e7f2b25a82da368ac97aac1f1ab505000256c1f5873e25
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.13.0 (2026-10-24)
4
+ <!-- title: the audit and the OpenAPI export follow mounted engines and Metal controllers, and a path variable is documented as one -->
5
+
6
+ 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.
7
+
8
+ 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.
9
+
10
+ ### Added
11
+ - **`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)
12
+
13
+ ### Changed
14
+ - **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)
15
+ - **`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)
16
+
17
+ ### Fixed
18
+ - **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)
19
+
3
20
  ## 0.12.0 (2026-10-21)
4
21
  <!-- title: stricter numbers, dates and class-load checks, and matchers that stop passing for the wrong reason -->
5
22
 
data/README.md CHANGED
@@ -904,7 +904,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
904
904
 
905
905
  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
906
 
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.
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, 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
908
 
909
909
  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
910
 
@@ -1241,7 +1241,7 @@ No Rails required for the core: `Permittable::Generator.draft(model: User)`, `.f
1241
1241
 
1242
1242
  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
1243
 
1244
- The audit crosses the contract registry with the **route set**, so a half-covered controller is as visible as an uncovered one:
1244
+ 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
1245
 
1246
1246
  ```sh
1247
1247
  bin/rails permittable:audit # the table plus a summary
@@ -1288,7 +1288,7 @@ A `config.exceptions_app = routes` setup (`match "/404", to: "errors#not_found",
1288
1288
 
1289
1289
  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
1290
 
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 audit concatenated route lists (an app's and an engine's) 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.
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 (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
1292
 
1293
1293
  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
1294
 
@@ -1312,7 +1312,9 @@ An entry is a controller path the way Rails routes it (the controller half of `b
1312
1312
 
1313
1313
  Assign the whole list. It is frozen, so add to it with `Permittable.audit_ignore += ["webhooks#receive"]`, which is validated too.
1314
1314
 
1315
- **Controllers outside `Rails.root` are ignored by default.** These are engines and gems: 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:
1315
+ **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.
1316
+
1317
+ **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
1318
 
1317
1319
  ```ruby
1318
1320
  Permittable.audit_ignore_outside_root = false
@@ -1457,7 +1459,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
1457
1459
 
1458
1460
  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
1461
 
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`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1462
+ **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.
1463
+
1464
+ **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
1465
 
1462
1466
  **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
1467
 
@@ -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.
@@ -373,7 +373,7 @@ module Permittable
373
373
  else
374
374
  free.each do |path, verb, template|
375
375
  holder = (paths[path] ||= {})
376
- placed = with_path_parameters(operation, path)
376
+ placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path)
377
377
  holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
378
378
  slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
379
379
  end
@@ -488,23 +488,93 @@ module Permittable
488
488
  # OpenAPI 3.1 requires every variable in a path template to be declared as
489
489
  # a path parameter — a document templating {id} without declaring it is
490
490
  # invalid, which every member route produced. The route set does not say
491
- # what an :id is and the exporter does not guess: a path segment arrives as
492
- # a string, so that is what it is documented as.
493
- def with_path_parameters(operation, path)
494
- variables = path.scan(/\{(\w+)\}/).flatten
491
+ # what an :id is, so a variable is a string unless the contract says
492
+ # otherwise: a rootless contract field named like one is that parameter's
493
+ # schema (path_fields), and leaves the body schema.
494
+ #
495
+ # `own` is the route's own spelling of the template, which differs
496
+ # from `path` when collapse_shapes placed the route under another
497
+ # route's spelling: the same shape, so the names pair up by position.
498
+ def with_path_parameters(operation, path, rule, own = path)
499
+ # A name the path templates twice (a mount and an engine route both
500
+ # name `:id`) is declared once: OpenAPI forbids two parameters with
501
+ # one name and location.
502
+ variables = path.scan(/\{(\w+)\}/).flatten.uniq
495
503
  return operation if variables.empty?
496
504
 
505
+ own_names = own.scan(/\{(\w+)\}/).flatten
506
+ fields = path_fields(rule, path.scan(/\{(\w+)\}/).flatten.zip(own_names).uniq(&:first))
497
507
  parameters = variables.map do |name|
498
- { "name" => name, "in" => "path", "required" => true, "schema" => { "type" => "string" } }
508
+ schema = fields[name] ? path_schema(fields[name]) : { "type" => "string" }
509
+ { "name" => name, "in" => "path", "required" => true, "schema" => schema }
499
510
  end
500
511
  # Inserted ahead of requestBody, where a reader of the document expects
501
512
  # it; emission stays deterministic either way.
502
513
  operation.each_with_object({}) do |(key, value), out|
503
- out["parameters"] = parameters if key == "requestBody"
504
- out[key] = value
514
+ if key == "requestBody"
515
+ out["parameters"] = parameters
516
+ value = body_without(rule, fields.values, own_names)
517
+ end
518
+ out[key] = value unless value.nil?
505
519
  end
506
520
  end
507
521
 
522
+ # { path variable => the contract's field for it }. Rails merges the
523
+ # route's path parameters over the request's, so a field the route fills
524
+ # from the URL is read from the URL, never from the body: `id` in the
525
+ # body is overwritten, and a request that leaves it out is fine. A rooted
526
+ # contract reads its fields from inside the root, which a path parameter
527
+ # never reaches, so only a rootless one has such fields, and only at its
528
+ # top level. `names` pairs each variable of the placed path with the
529
+ # route's own name for it.
530
+ #
531
+ # Only a scalar field moves. A URL segment is one string, so an array or
532
+ # a hash can never be what it carries (`POST /defs/1,2` against
533
+ # `array :id` is a 422). Such a field stays in the body, where a client
534
+ # can at least say what it means.
535
+ def path_fields(rule, names)
536
+ return {} if rule[:root]
537
+
538
+ names.filter_map do |variable, own|
539
+ field = rule[:fields].find { |f| f[:name].to_s == own }
540
+ [variable, field] if field && field[:kind] == :scalar
541
+ end.to_h
542
+ end
543
+
544
+ # A moved field's schema as the path parameter documents it. The URL
545
+ # always supplies the segment, so a `default:` never applies and the
546
+ # value is never null: `nullable:` would only add "null" to the type.
547
+ def path_schema(field)
548
+ JsonSchema.field(field.except(:default, :nullable))
549
+ end
550
+
551
+ # The requestBody for `rule` without the fields a path parameter took
552
+ # over; nil, to leave it out, when that was every field. Such a body
553
+ # would be an empty object. An action declared with no fields at all
554
+ # keeps the empty body it always had.
555
+ #
556
+ # `unknown: :error` forbids every key the contract does not declare, but
557
+ # the runtime exempts the route's path parameters from that check and
558
+ # then overwrites them with the URL's values: `PATCH /widgets/7` with
559
+ # {"id":7,"name":"x"} is a 200 whether or not the contract declares `id`.
560
+ # That is the payload a Rails form, or a client sending a record back,
561
+ # produces, so the schema must not forbid it. Each name the route
562
+ # templates therefore stays in the body as an unconstrained property,
563
+ # any value and never required, since the value is read from the URL and
564
+ # nothing in the body can make the request wrong. A rooted contract
565
+ # needs none of this: its wrapper object is permissive already.
566
+ def body_without(rule, moved, templated)
567
+ rest = rule[:fields] - moved
568
+ return if rest.empty? && !moved.empty?
569
+
570
+ body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
571
+ return body unless rule[:unknown] == :error && !rule[:root]
572
+
573
+ properties = body["content"]["application/json"]["schema"]["properties"]
574
+ templated.each { |name| properties[name] ||= {} }
575
+ body
576
+ end
577
+
508
578
  # Drops every descriptor for a verb and concrete path that an earlier,
509
579
  # unconstrained route already answers (rails_routes calls it last).
510
580
  # Journey sorts the routes matching a request by precedence, their
@@ -533,10 +603,13 @@ module Permittable
533
603
  #
534
604
  # Descriptors arrive in route order (then verb, then optional variant),
535
605
  # so the first one seen is the first route. Precedence is a property of
536
- # ONE route set, which is why this runs inside each rails_routes call and
537
- # not in the readers: an engine's routes are relative to its mount point,
538
- # and a list concatenated from an app's and an engine's must not let one
539
- # shadow the other.
606
+ # ONE route table, which is why this runs once inside each rails_routes
607
+ # call and not in the readers: a list concatenated from an app's and an
608
+ # engine's must not let one shadow the other. An engine followed from its
609
+ # mount is part of that one table, expanded where the mount is drawn, so
610
+ # an app route ahead of the mount hides the engine's route for the same
611
+ # slot and an engine route hides a later app one (see rails_routes). An
612
+ # engine route is unconditional only when its mount is as well.
540
613
  #
541
614
  # Two more limits, both towards listing more. Only identical templates
542
615
  # are compared, so templates that differ at all, even only in a
@@ -616,9 +689,77 @@ module Permittable
616
689
  # A verb on a path that an earlier, unconstrained route already answers
617
690
  # is left out: Rails never dispatches it to the later route (see
618
691
  # drop_shadowed).
619
- def rails_routes(app)
620
- descriptors = app.routes.routes.each_with_index.flat_map do |route, index|
692
+ #
693
+ # A mounted Rails engine is part of the application: `mount Billing::Engine
694
+ # => "/billing"` makes every route the engine draws a URL of the app, so
695
+ # the audit's gate and the export must see them. With `engines: true`
696
+ # (the default) each such mount is followed into the engine's own route
697
+ # set, and the mount's path is put in front of every path found there,
698
+ # token by token, so a mount under `scope "(:locale)"`, one at a
699
+ # dynamic `at: ":tenant"` and one at "/" expand and join exactly as an
700
+ # app route would. A route reached through a mount carries its position
701
+ # as an Array, `[mount, route]` (`[3, 0, 5]` through an engine mounted in
702
+ # an engine), where an app route carries a bare Integer; so one engine
703
+ # mounted twice is two routes to its actions, as it is to a client. Only
704
+ # a Rails engine is followed: a Sidekiq::Web or a proc has no route set
705
+ # of ours to read and is skipped, as it always was. `engines: false`
706
+ # reads the one route set alone, for a caller that concatenates the lists
707
+ # of an app and its engines itself.
708
+ #
709
+ # Rails tries the routes in order with a mount standing where it is
710
+ # drawn, so the engine's routes are judged in that one order with the
711
+ # app's (see drop_shadowed): an app route ahead of the mount answers
712
+ # before the engine is asked, an engine route that matches answers and
713
+ # leaves an app route behind the mount unreached, and only a request the
714
+ # engine has no route for goes on (X-Cascade). A mount's `via:` limits
715
+ # the verbs it hands over: another verb is passed on, never answered by
716
+ # the engine.
717
+ #
718
+ # A parameter the mount and an engine route both name (`/{id}/.../{id}`)
719
+ # is not renamed: the template names it twice, the operation declares it
720
+ # once, and the README says so.
721
+ def rails_routes(app, engines: true)
722
+ drop_shadowed(collect_routes(app.routes, engines: engines, prefixes: [Prefix.new([], [])], parents: [],
723
+ visiting: [].freeze, reachable: true))
724
+ end
725
+
726
+ # What a route set mounted at one variant of its mount's path is led by.
727
+ # `tokens` are the mount's path from the root (see join_paths).
728
+ # `shadowed_by` are the paths of the variants Rails tries first, which
729
+ # leave this one nothing that they accept: a mount at "/" under
730
+ # `scope "(:locale)"` is matched as `/{locale}` before it is matched as
731
+ # "/", so /posts is the locale "posts" asking for the engine's root, and
732
+ # the engine's own /posts is not reached through the second variant. The
733
+ # mount's path is matched as a whole, unanchored and with no way back
734
+ # into it once the engine has been asked, so a variant that fits the
735
+ # leading segments of a path takes it whether or not the engine has a
736
+ # route for the rest.
737
+ Prefix = Struct.new(:tokens, :shadowed_by)
738
+
739
+ # The descriptors of one route set (`set`, anything with `routes`), each
740
+ # path led by every one of `prefixes` (a Prefix for each variant of the
741
+ # mounts above it, one empty one at the top) and each `route:` led by
742
+ # `parents`. `reachable` is whether every mount above answers every
743
+ # request for its path (see unconditional?); a route behind one that
744
+ # does not may be passed over, so it hides nothing. `visiting` is the
745
+ # route sets above this one, so an engine that mounts itself, or an app
746
+ # and engine that mount each other, ends the walk instead of recursing
747
+ # for good. Rails would loop on such a request too; the audit must not
748
+ # hang on it.
749
+ #
750
+ # The descriptors still carry `unconditional:`; the caller runs
751
+ # drop_shadowed over the whole list, mounts expanded in place.
752
+ def collect_routes(set, engines:, prefixes:, parents:, visiting:, reachable:)
753
+ visiting += [set]
754
+ set.routes.each_with_index.flat_map do |route, index|
755
+ id = parents.empty? ? index : parents + [index]
621
756
  requirements = route.requirements
757
+ if engines && (mounted = mounted_routes(route)) && visiting.none? { |above| above.equal?(mounted) }
758
+ found = collect_routes(mounted, engines: engines, prefixes: mount_prefixes(prefixes, route), parents: Array(id),
759
+ visiting: visiting, reachable: reachable && unconditional?(route))
760
+ verbs = route.verb.to_s.downcase.split("|")
761
+ next verbs.empty? ? found : found.select { |descriptor| verbs.include?(descriptor[:verb]) }
762
+ end
622
763
  verb = route.verb.to_s
623
764
  next [] if requirements[:controller].nil? || requirements[:action].nil?
624
765
 
@@ -627,9 +768,9 @@ module Permittable
627
768
  # audit entirely. Expand it into the verbs it actually answers.
628
769
  verb = ALL_VERBS if verb.empty?
629
770
 
630
- variants = optional_variants(route.path.spec, requirements)
771
+ variants = optional_variants(route.path.spec, requirements, prefixes)
631
772
  # Read once per route; drop_shadowed reads it, then strips it.
632
- unconditional = unconditional?(route)
773
+ unconditional = reachable && unconditional?(route)
633
774
  # One route can answer several verbs (`match via: [:patch, :put]`, and
634
775
  # the PATCH|PUT pair resources generates); documenting only the first
635
776
  # dropped the others from the export entirely. Verb by verb, then
@@ -638,12 +779,47 @@ module Permittable
638
779
  verb.split("|").flat_map do |single|
639
780
  variants.map do |path, shadows|
640
781
  descriptor = { controller: requirements[:controller], action: requirements[:action],
641
- verb: single.downcase, path: path, route: index, unconditional: unconditional }
782
+ verb: single.downcase, path: path, route: id, unconditional: unconditional }
642
783
  shadows.empty? ? descriptor : descriptor.merge(shadows: shadows)
643
784
  end
644
785
  end
645
786
  end
646
- drop_shadowed(descriptors)
787
+ end
788
+
789
+ # The Prefix of each variant of a mount's path under each of `prefixes`,
790
+ # outermost mount first, and within a mount present before absent, which
791
+ # is the order Rails' regexp tries them in. A variant is shadowed by every
792
+ # variant ahead of it: those of the same mount, and (through the
793
+ # `shadowed_by` it inherits) those of a mount above, judged where each
794
+ # was matched.
795
+ def mount_prefixes(prefixes, route)
796
+ own = variant_tokens(route.path.spec, route.requirements)
797
+ prefixes.flat_map do |above|
798
+ own.each_with_index.map do |tokens, index|
799
+ Prefix.new(join_paths(above.tokens, tokens),
800
+ above.shadowed_by + own.take(index).map { |earlier| join_paths(above.tokens, earlier) })
801
+ end
802
+ end
803
+ end
804
+
805
+ # The route set a mount route hands requests to, or nil for any other
806
+ # route. Rails itself asks `route.app.engine?` (RoutesInspector does)
807
+ # and reads the set off `rack_app`. A route that cannot answer, such as a
808
+ # hand-built duck, is not a mount.
809
+ def mounted_routes(route)
810
+ return unless route.respond_to?(:app) && route.app.respond_to?(:engine?) && route.app.engine?
811
+
812
+ engine = route.app.rack_app
813
+ engine.routes if engine.respond_to?(:routes)
814
+ end
815
+
816
+ # `outer` then `inner`, as one path: the slash the mount ends in and the
817
+ # one the inner path starts with are one slash, and a route to the
818
+ # engine's root ("/") adds nothing to the mount's own path.
819
+ def join_paths(outer, inner)
820
+ inner = inner.drop(1) if outer.last == "/" && inner.first == "/"
821
+ inner = [] if inner == ["/"] && !outer.empty?
822
+ join_literals(outer + inner)
647
823
  end
648
824
 
649
825
  # A route parameter as optional_variants reads it: a `:name` segment or a
@@ -704,13 +880,23 @@ module Permittable
704
880
  # string: `to_s` prints an escaped parenthesis in a literal as a bare
705
881
  # one, which a string scan then read as a group. A Journey-shaped stand-in
706
882
  # may carry a String spec instead (string_variants).
707
- def optional_variants(spec, requirements = {})
708
- variants = if spec.respond_to?(:type)
709
- tree_variants(spec, requirements)
710
- else
711
- string_variants(spec.to_s.sub(FORMAT_GROUP, ""), requirements).first
712
- end
713
- variants = variants.map { |tokens| join_literals(tokens) }
883
+ #
884
+ # `prefixes` are the Prefix of each variant of the mounts the route sits
885
+ # under (one empty one, none, by default). Each is put in front of every
886
+ # variant, the mount's variants outermost, which is the order Rails'
887
+ # regexp tries them in; a path a mount variant ahead of its own takes
888
+ # first is left out (see Prefix), and the shadowing above then judges the
889
+ # rest.
890
+ def optional_variants(spec, requirements = {}, prefixes = [Prefix.new([], [])])
891
+ # The route's own variants are judged before a mount's path is put in
892
+ # front of them: an empty one (`(:locale(/:region))` with nothing
893
+ # present) is no request path, but joined onto a mount it would read as
894
+ # the mount's own path, which Rails does not hand to that route.
895
+ own = variant_tokens(spec, requirements).select { |tokens| request_path?(tokens) }
896
+ variants = prefixes.flat_map do |prefix|
897
+ own.map { |tokens| join_paths(prefix.tokens, tokens) }
898
+ .reject { |joined| prefix.shadowed_by.any? { |earlier| shadowed?(earlier, joined.first(earlier.length)) } }
899
+ end
714
900
  # No group, no choice: nothing for one variant to shadow.
715
901
  return { render(variants.first) => [] } if variants.one?
716
902
 
@@ -732,6 +918,17 @@ module Permittable
732
918
  kept.to_a.reverse.to_h
733
919
  end
734
920
 
921
+ # The variants of a path spec, each a list of tokens with adjacent text
922
+ # joined (see join_literals), before any is dropped as shadowed.
923
+ def variant_tokens(spec, requirements)
924
+ variants = if spec.respond_to?(:type)
925
+ tree_variants(spec, requirements)
926
+ else
927
+ string_variants(spec.to_s.sub(FORMAT_GROUP, ""), requirements).first
928
+ end
929
+ variants.map { |tokens| join_literals(tokens) }
930
+ end
931
+
735
932
  # A Journey tree's variants, each a list of tokens: literal text and
736
933
  # Param. A group yields its contents' variants, then the empty one —
737
934
  # 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
- controllers = bases.flat_map(&:descendants).uniq.select(&:name)
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
- bases = []
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
- bases = []
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
 
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.12.0".freeze
2
+ VERSION = "0.13.0".freeze
3
3
  end
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.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport