permittable 0.2.0 → 0.4.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: 577c3a6c767e85dd2b3fc0ad8049e1d0ec77d2b87bc1f6eedca351161f589ef9
4
- data.tar.gz: bed2c92d56a70abf45f8f9cc4b779788886623b442054d63727893f891eb0c13
3
+ metadata.gz: e6009aed769f45203bb8053f4310d4ac986ec941815704e613f63bddf819bf92
4
+ data.tar.gz: 721cead2003e010c773e4bab9de20830d85f40182a049b8e4db6fa82d1f377d7
5
5
  SHA512:
6
- metadata.gz: bd955e930e66036f2bffd9f006996ad0713e3582eda8386e51bc668d42432db7f941bef9724ba5c6589e85be4ce91827cc8536f49ca6c0d698562de0aee65520
7
- data.tar.gz: a61f63950413801e405a6690e141a35616d974cc347bdae86e9a6f239a75e89225604b973acb4daf4b7daa25492e767175f674de6376df62eb0128e27f16fa62
6
+ metadata.gz: 5ff703088a1c1389cc49150521380c56f937d4ba0f99de0137e7390a91fa66a16bdad09092f0c65964b94e332bee170b17bdc6d7e9d03c11a7b2020f95953c78
7
+ data.tar.gz: 64b5097368d5df03d8dfacb4c9eb79cf6ecb08e2c0b933a80281201139e496f143f0336d886d4a4d33a15dfc0f2c7642f10ee5641e625874a80526717386d869
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.4.0 (2026-08-24)
4
+ <!-- title: monitor mode -->
5
+
6
+ The rollout switch. Adopting contracts on a live API — or tightening an existing one — used to mean flipping unknown clients from "accepted" to "422" in a single deploy. A contract can now run in **monitor mode**: the full pipeline executes (unwrap, cast, validate, defaults), but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed. Deploy monitoring, dashboard the would-be rejections, then enforce controller by controller — every 422 you finally return is one you already counted.
7
+
8
+ ### Added
9
+ - **`mode: :monitor` on `permit_params`, and an app-wide `Permittable.mode` default** (`:enforce` unless set; a rule's own `mode:` always wins, in both directions). On a violating request in monitor mode nothing raises and nothing renders: the `invalid_parameters.permittable` event fires with `mode: :monitor`, the logger warns with the offending paths, and `permitted_params` returns the **raw pass-through** — exactly what the client sent, no casts, no defaults, no transforms (a missing `root:` passes an empty hash; a rootless contract drops only the router's bookkeeping keys). Monitor rules validate **eagerly in the `before_action` regardless of `enforce:`**, so telemetry never depends on the action calling `permitted_params` — legacy actions still reading `params` directly are exactly the ones being monitored.
10
+ - **`permittable_violations(action = nil)`** — the recorded violation details for the (memoized) validation of `action`, `[]` when the request was clean. The monitor-mode observable; under enforce it swallows its own trigger's raise, making "would this request fail?" a one-liner in tests.
11
+ - **`mode:` key on the `invalid_parameters.permittable` payload** (`:enforce` / `:monitor`), so one subscriber can dashboard enforced rejections and monitored would-be rejections side by side. Additive — existing subscribers are unaffected.
12
+ - **`x-permittable-mode: "monitor"`** on exported OpenAPI operations whose rule declares monitor mode — the docs must not promise a 422 the server doesn't yet send. Only the per-rule declaration is exported; the app-wide `Permittable.mode` is runtime configuration, not contract data.
13
+
14
+ Contracts that don't opt in are byte-for-byte unaffected: the default mode is `:enforce` and the enforce path behaves exactly as before.
15
+
16
+ ## 0.3.0 (2026-08-24)
17
+ <!-- title: OpenAPI export -->
18
+
19
+ Contracts gain a third reader. The registry that already drives the validator and the schema-drift guard now also generates **OpenAPI 3.1** — because the schema is emitted from the same frozen data the server enforces, the docs cannot drift from the validation. Fully additive; no behaviour of existing contracts changes.
20
+
21
+ ### Added
22
+ - **`Permittable::JsonSchema`** — converts rules and fields into JSON Schema (draft 2020-12): types map onto their canonical JSON encodings (`:decimal` as `["string", "number"]` + `format: decimal`), `in:` → `enum`/`minimum`/`maximum`, `length:` → `minLength`/`maxLength` or `minItems`/`maxItems`, `format:` → `pattern` with `\A`/`\z` translated to `^`/`$`, `default:` → `default`, `unknown: :error` → `additionalProperties: false` at every level, `root:` → a required wrapper object, `sensitive:` → `writeOnly: true`. Required strings get `minLength: 1` (`""` is absent). What has no ECMA/JSON-Schema equivalent stays visible instead of guessed: Ruby-only or flagged regexps export as `x-permittable-pattern`, `validate:`/`transform:` as `x-permittable-custom-validation`/`x-permittable-transformed`, non-numeric Ranges as `x-permittable-range`. Emission is deterministic, so generated documents are committable and diff-stable.
23
+ - **`Permittable::OpenAPI`** — assembles full OpenAPI 3.1 documents (`.document`) and fragments (`.request_body_for`, `.operations_for`, `.components`) from any set of controllers, plain Ruby, no Rails required. Operations resolve through `permit_rule_for`, so last-matching-rule-wins holds in the docs exactly as at request time; every operation references shared components typing the 422 (and, for rooted contracts, 400) error envelope. Catch-all rules expand through `action_methods` (the concern's own public methods excluded) or surface as `"*"` + `x-permittable-catch-all`; unrouted operations land in `x-permittable-controllers` rather than being dropped.
24
+ - **`bin/rails permittable:openapi[output]`** — rake task (loaded by the Railtie) that eager-loads the app, collects every controller with contracts, maps actions onto `paths` via the route set (`:id` → `{id}`), and prints or writes the document. `OPENAPI_TITLE`/`OPENAPI_VERSION` override the `info` block.
25
+ - **`desc:` and `example:` field options, `desc:` on `permit_params`** — documentation passthrough carried on the frozen contract data and ignored by the runtime. An `example:` is validated against its own field's contract at class load, exactly like `default:`, so published examples can't lie either.
26
+
3
27
  ## 0.2.0 (2026-08-18)
4
28
  <!-- title: custom error messages -->
5
29
 
data/README.md CHANGED
@@ -54,6 +54,7 @@ A violating request never reaches your action:
54
54
  - [Violations and error responses](#violations-and-error-responses) · [Custom error messages](#custom-error-messages-message) · [Unknown parameters](#unknown-parameters)
55
55
  - [Output reshaping](#output-reshaping-transform-and-finalize) · [The schema-drift guard](#the-schema-drift-guard)
56
56
  - [Sensitive parameters](#sensitive-parameters-and-log-redaction) · [Instrumentation](#instrumentation)
57
+ - [Monitor mode](#monitor-mode-roll-out-without-rejecting) · [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
57
58
  - [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
58
59
 
59
60
  ---
@@ -70,6 +71,8 @@ A violating request never reaches your action:
70
71
  | Machine-readable error details | ❌ | ❌ | ✅ |
71
72
  | Reshapes output | ❌ | ❌ | ✅ |
72
73
  | Checked against your schema at boot | ❌ | ❌ | ✅ |
74
+ | Exports OpenAPI / JSON Schema | ❌ | ❌ | ✅ |
75
+ | Report-only rollout mode | ❌ | ❌ | ✅ |
73
76
 
74
77
  The design rests on one idea: **a contract is data, not code.** It is declared once at the class level, frozen, inheritable, and introspectable. Everything else here follows from that — the drift guard can read it at boot, `finalize` can run on a bare object with no controller state, and the whole contract can be printed or tested without a request.
75
78
 
@@ -106,10 +109,12 @@ params
106
109
 
107
110
  Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
108
111
 
112
+ In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
113
+
109
114
  ## Declaring a contract
110
115
 
111
116
  ```ruby
112
- permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &contract)
117
+ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &contract)
113
118
  ```
114
119
 
115
120
  | Option | Default | Meaning |
@@ -119,6 +124,8 @@ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: fals
119
124
  | `model:` | `nil` | Model class, or `true` to infer from `controller_name`, enabling the [drift guard](#the-schema-drift-guard) |
120
125
  | `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — how to treat undeclared keys |
121
126
  | `enforce:` | `false` | `false` validates lazily on first use; `true` validates in a `before_action` |
127
+ | `mode:` | `nil` | `nil` follows `Permittable.mode`; `:monitor` reports violations instead of rejecting — see [monitor mode](#monitor-mode-roll-out-without-rejecting) |
128
+ | `desc:` | `nil` | Documentation only — becomes the operation description in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
122
129
 
123
130
  `permit_params` is **repeatable**, and **the last matching rule wins**. Contracts behave like configuration: a base controller declares a catch-all, and a subclass overrides it for specific actions.
124
131
 
@@ -186,6 +193,8 @@ Which options are legal depends on the field kind — anything else raises at cl
186
193
  | `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
187
194
  | `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
188
195
  | `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
196
+ | `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
197
+ | `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
189
198
 
190
199
  ¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
191
200
 
@@ -378,14 +387,99 @@ ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*,
378
387
  end
379
388
  ```
380
389
 
390
+ ## Monitor mode (roll out without rejecting)
391
+
392
+ Adopting contracts on a live API — or tightening one field on an existing contract — has a chicken-and-egg problem: you cannot know what the 422s would break until you enforce them, and you dare not enforce them until you know. Old mobile app versions, third-party integrations, and forgotten cron jobs all send what they send. `mode: :monitor` resolves it: the full pipeline runs (unwrap, cast, validate, defaults), but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed.
393
+
394
+ ```ruby
395
+ class OrdersController < ApplicationController
396
+ permit_params :create, root: :order, mode: :monitor do
397
+ required :sku, :string
398
+ optional :quantity, :integer, in: 1..99
399
+ end
400
+
401
+ # The action doesn't have to change while monitoring — it can keep reading
402
+ # params the old way; the contract validates in the before_action.
403
+ end
404
+ ```
405
+
406
+ Or flip the whole app at once and pin controllers to their final mode one at a time — a rule's own `mode:` always beats the global, in both directions:
407
+
408
+ ```ruby
409
+ # config/initializers/permittable.rb
410
+ Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym
411
+ ```
412
+
413
+ On a violating request in monitor mode:
414
+
415
+ - **Nothing raises and nothing renders** — the action runs.
416
+ - The [`invalid_parameters.permittable` event](#instrumentation) fires with `mode: :monitor` in the payload (enforced violations carry `mode: :enforce`), and the logger warns with the offending paths. Point your existing subscriber at a dashboard and you have a per-controller rollout report.
417
+ - `permitted_params` returns the **raw pass-through**: exactly what the client sent, untouched — no casts, no defaults, no transforms. A missing `root:` passes an empty hash (the envelope you asked for isn't there); a rootless contract drops only Rails' routing keys.
418
+ - `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
419
+
420
+ Monitor-mode rules validate **eagerly in the `before_action`, regardless of `enforce:`** — telemetry must not depend on the action calling `permitted_params`, since legacy actions still reading `params` directly are exactly the ones worth monitoring. (On a plain-Ruby host without `before_action`, validation stays lazy.)
421
+
422
+ The rollout recipe:
423
+
424
+ 1. Write contracts for a legacy controller. The action code stays as-is.
425
+ 2. Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; telemetry starts.
426
+ 3. Watch the dashboard. Every entry is a real client that would have been rejected — fix the contract, or wait for that traffic to drain.
427
+ 4. Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
428
+
429
+ [Exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) marks operations whose rule declares `mode: :monitor` with `x-permittable-mode: "monitor"` — the docs shouldn't promise a 422 the server doesn't yet send. Only the per-rule declaration is exported: the global `Permittable.mode` is runtime configuration, not contract data.
430
+
431
+ ## Exporting OpenAPI (docs that cannot drift)
432
+
433
+ Because a contract is data, it has a third reader beyond the validator and the drift guard: an exporter that emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema). The schema is generated from the same frozen data the server enforces, so — like the drift guard, pointed outward — the docs cannot lie:
434
+
435
+ ```sh
436
+ bin/rails permittable:openapi # JSON to stdout
437
+ bin/rails "permittable:openapi[openapi/api.json]" # write to a file
438
+ ```
439
+
440
+ The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
441
+
442
+ Or build fragments programmatically — no Rails required:
443
+
444
+ ```ruby
445
+ Permittable::JsonSchema.rule(UsersController.permit_rule_for(:create)) # request-body schema
446
+ Permittable::OpenAPI.request_body_for(UsersController, :create) # OpenAPI requestBody object
447
+ Permittable::OpenAPI.operations_for(UsersController) # { action => operation }
448
+ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
449
+ ```
450
+
451
+ How contracts map:
452
+
453
+ | Contract | Emitted schema |
454
+ |---|---|
455
+ | `required` / `optional` | the object's `required:` array; required strings also get `minLength: 1` (`""` is absent) |
456
+ | `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
457
+ | `:date` / `:datetime` | `string` + `format: date` / `date-time` |
458
+ | `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
459
+ | `in:` Array / numeric Range | `enum` / `minimum` + `maximum` (exclusive ends honoured) |
460
+ | `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
461
+ | `format:` | `pattern`, with `\A`/`\z` translated to `^`/`$` |
462
+ | `default:` / `desc:` / `example:` | `default` / `description` / `examples` |
463
+ | nested block / `array` | `object` + `properties` / `array` + `items` |
464
+ | `unknown: :error` | `additionalProperties: false`, at every nesting level |
465
+ | `root:` | the wrapping object, itself required |
466
+ | `sensitive: true` | `writeOnly: true` (never echoed in responses) |
467
+
468
+ 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.
469
+
470
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; 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 land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
471
+
472
+ Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
473
+
381
474
  ## API reference
382
475
 
383
476
  ### Instance methods
384
477
 
385
478
  | Method | Purpose |
386
479
  |---|---|
387
- | `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation, or `ArgumentError` when no contract covers the action |
388
- | `enforce_params_contract` | The `before_action` entry point. Only validates rules declared `enforce: true`. Public, so hosts can `skip_before_action` it |
480
+ | `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation (in [monitor mode](#monitor-mode-roll-out-without-rejecting), returns the raw pass-through instead), or `ArgumentError` when no contract covers the action |
481
+ | `permittable_violations(action = action_name)` | The violation details recorded by validating `action` `[]` when clean. Triggers the same memoized validation; under enforce it swallows the raise, making "would this request fail?" a one-liner |
482
+ | `enforce_params_contract` | The `before_action` entry point. Validates rules declared `enforce: true` and all [monitor-mode](#monitor-mode-roll-out-without-rejecting) rules. Public, so hosts can `skip_before_action` it |
389
483
  | `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
390
484
 
391
485
  ### Class methods
@@ -402,7 +496,10 @@ end
402
496
  |---|---|
403
497
  | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
404
498
  | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
499
+ | `Permittable.mode` / `Permittable.mode=` | App-wide default (`:enforce`) for rules that don't declare their own `mode:` |
405
500
  | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
501
+ | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
502
+ | `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
406
503
 
407
504
  ## Errors caught at class load
408
505
 
@@ -415,12 +512,13 @@ A bad contract is a programmer error, so it fails when the class loads — never
415
512
  - `format:`, `length:`, or `normalize:` on a non-`:string` field
416
513
  - `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
417
514
  - `validate:` or `transform:` that isn't callable
418
- - A `default:` that violates its own field's contract, or an array `default:` whose elements violate `of:`
515
+ - A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
419
516
  - `required: true` combined with `default:`
420
517
  - A field given both a type and a nested block; an array given both `of:` and a block
421
518
  - An empty contract, or a nested block declaring no sub-fields
422
519
  - `finalize` declared twice, without a block, or inside a nested block
423
520
  - `permit_params` without a block, or an invalid `unknown:` mode
521
+ - An invalid `mode:` (and `Permittable.mode =` rejects invalid values at assignment)
424
522
  - A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
425
523
 
426
524
  ## Compatibility
@@ -438,7 +536,7 @@ Using [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `Concer
438
536
 
439
537
  ```sh
440
538
  bundle install
441
- bundle exec rspec # 76 examples
539
+ bundle exec rspec # 125 examples
442
540
  bundle exec rubocop
443
541
  ```
444
542
 
@@ -0,0 +1,196 @@
1
+ module Permittable
2
+ # Converts frozen contract data — the rule and field hashes built by
3
+ # ContractBuilder — into JSON Schema (draft 2020-12, the dialect OpenAPI 3.1
4
+ # request bodies use). This is the third reader of the contract registry,
5
+ # after the request validator and the column guard: because a contract is
6
+ # data, a schema exported from it cannot drift from what the server
7
+ # actually enforces.
8
+ #
9
+ # The exported schema describes the DECLARED INPUT SHAPE in its canonical
10
+ # JSON encoding. Two deliberate consequences:
11
+ # * Coercion additionally accepts string-encoded scalars ("42", "true")
12
+ # for form/query payloads; the schema documents the JSON types only.
13
+ # * `validate:`/`transform:`/`finalize` are opaque callables — they never
14
+ # change what a client may SEND, so fields carrying them are flagged
15
+ # with `x-permittable-*` extensions rather than mistranslated.
16
+ #
17
+ # Emission is deterministic (fixed key insertion order, declaration-order
18
+ # properties) so generated documents are committable and diff-stable.
19
+ module JsonSchema
20
+ module_function
21
+
22
+ SCALAR_SCHEMAS = {
23
+ string: { "type" => "string" },
24
+ integer: { "type" => "integer" },
25
+ float: { "type" => "number" },
26
+ # Coercion accepts Numeric or String for :decimal; string is the
27
+ # precision-safe form, so both encodings are documented.
28
+ decimal: { "type" => %w[string number], "format" => "decimal" },
29
+ boolean: { "type" => "boolean" },
30
+ date: { "type" => "string", "format" => "date" },
31
+ datetime: { "type" => "string", "format" => "date-time" }
32
+ }.freeze
33
+
34
+ # Ruby regexp constructs with no ECMA-262 equivalent (\Z, \h, \K, \R, \G,
35
+ # inline flag groups, absence operator, conditionals, POSIX classes,
36
+ # possessive quantifiers). A source matching this is left untranslated —
37
+ # the scan is deliberately over-eager on escaped lookalikes because a
38
+ # wrong pattern in published docs is worse than a missing one.
39
+ UNTRANSLATABLE = /
40
+ \\[ZhHKRG] |
41
+ \(\?[a-z-]+[:)] |
42
+ \(\?~ |
43
+ \(\?\( |
44
+ \[\[: |
45
+ [*+?]\+
46
+ /x
47
+
48
+ # Request-body schema for one rule from `permittable_contracts` /
49
+ # `permit_rule_for`: the object schema of its fields, wrapped in the
50
+ # `root:` envelope when the rule declares one. The wrapper itself stays
51
+ # permissive even under `unknown: :error` — the runtime never inspects
52
+ # sibling keys outside the root.
53
+ def rule(permit_rule)
54
+ schema = object(permit_rule[:fields], unknown: permit_rule[:unknown])
55
+ return schema unless permit_rule[:root]
56
+
57
+ root = permit_rule[:root].to_s
58
+ { "type" => "object", "properties" => { root => schema }, "required" => [root] }
59
+ end
60
+
61
+ # Object schema for a field list; `unknown:` applies at every nesting
62
+ # level, exactly like the runtime check.
63
+ def object(fields, unknown: :ignore)
64
+ schema = {
65
+ "type" => "object",
66
+ "properties" => fields.to_h { |f| [f[:name].to_s, field(f, unknown: unknown)] }
67
+ }
68
+ required = fields.select { |f| f[:required] }.map { |f| f[:name].to_s }
69
+ schema["required"] = required unless required.empty?
70
+ schema["additionalProperties"] = false if unknown == :error
71
+ schema
72
+ end
73
+
74
+ # Schema fragment for one field hash of any kind.
75
+ def field(field, unknown: :ignore)
76
+ schema = case field[:kind]
77
+ when :scalar then scalar_schema(field)
78
+ when :nested then object(field[:fields], unknown: unknown)
79
+ when :array then array_schema(field, unknown: unknown)
80
+ end
81
+ annotate(schema, field)
82
+ end
83
+
84
+ def scalar_schema(field)
85
+ schema = SCALAR_SCHEMAS.fetch(field[:type]).dup
86
+ apply_in!(schema, field[:in])
87
+ apply_string_bounds!(schema, field)
88
+ apply_pattern!(schema, field[:format])
89
+ schema
90
+ end
91
+
92
+ def array_schema(field, unknown:)
93
+ schema = { "type" => "array" }
94
+ min, max = length_bounds(field[:length])
95
+ schema["minItems"] = min if min
96
+ schema["maxItems"] = max if max
97
+ schema["items"] = field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).dup
98
+ schema
99
+ end
100
+
101
+ def apply_in!(schema, allowed)
102
+ return unless allowed
103
+
104
+ unless allowed.is_a?(Range)
105
+ schema["enum"] = allowed.map { |v| json_value(v) }
106
+ return
107
+ end
108
+ # Runtime bounds-checks Ranges with cover?; numeric endpoints map onto
109
+ # minimum/maximum, anything else (a Range of strings) has no JSON
110
+ # Schema equivalent and is carried as an extension.
111
+ unless allowed.begin.is_a?(Numeric) || allowed.end.is_a?(Numeric)
112
+ schema["x-permittable-range"] = allowed.inspect
113
+ return
114
+ end
115
+ schema["minimum"] = json_value(allowed.begin) if allowed.begin
116
+ schema[allowed.exclude_end? ? "exclusiveMaximum" : "maximum"] = json_value(allowed.end) if allowed.end
117
+ end
118
+
119
+ def apply_string_bounds!(schema, field)
120
+ return unless field[:type] == :string
121
+
122
+ min, max = length_bounds(field[:length])
123
+ # "" is ABSENT and an absent required field violates, so a required
124
+ # string can never validly be empty — the schema says so.
125
+ min = 1 if field[:required] && min.to_i < 1
126
+ schema["minLength"] = min if min
127
+ schema["maxLength"] = max if max
128
+ end
129
+
130
+ def apply_pattern!(schema, regexp)
131
+ return unless regexp
132
+
133
+ pattern = ecma_pattern(regexp)
134
+ if pattern
135
+ schema["pattern"] = pattern
136
+ else
137
+ schema["x-permittable-pattern"] = regexp.inspect
138
+ end
139
+ end
140
+
141
+ # Conservative Ruby → ECMA-262 translation: \A/\z anchors become ^/$.
142
+ # Flagged regexps bail entirely (JSON Schema's `pattern` has no flag
143
+ # slot, and /x//m/i all change semantics), as does any source containing
144
+ # an untranslatable construct.
145
+ def ecma_pattern(regexp)
146
+ return nil unless regexp.options.zero?
147
+
148
+ source = regexp.source
149
+ return nil if source.match?(UNTRANSLATABLE)
150
+
151
+ source.gsub('\A', "^").gsub('\z', "$")
152
+ end
153
+
154
+ # length: reasons about characters on strings and element count on
155
+ # arrays; either way it is an exact Integer or a Range (possibly endless
156
+ # / beginless, possibly exclusive).
157
+ def length_bounds(spec)
158
+ case spec
159
+ when Integer then [spec, spec]
160
+ when Range
161
+ max = spec.end && spec.exclude_end? ? spec.end - 1 : spec.end
162
+ [spec.begin, max]
163
+ else [nil, nil]
164
+ end
165
+ end
166
+
167
+ # Documentation keys shared by every field kind. `default:`/`example:`
168
+ # are authored values (possibly Date/Time/BigDecimal literals), so they
169
+ # are re-encoded as JSON scalars.
170
+ def annotate(schema, field)
171
+ schema["default"] = json_value(field[:default]) if field.key?(:default)
172
+ schema["examples"] = [json_value(field[:example])] if field.key?(:example)
173
+ schema["description"] = field[:desc] if field[:desc]
174
+ if field[:sensitive]
175
+ schema["writeOnly"] = true
176
+ schema["x-permittable-sensitive"] = true
177
+ end
178
+ schema["x-permittable-custom-validation"] = true if field[:validate]
179
+ schema["x-permittable-transformed"] = true if field[:transform]
180
+ schema
181
+ end
182
+
183
+ def json_value(value)
184
+ case value
185
+ when Array then value.map { |v| json_value(v) }
186
+ when BigDecimal then value.to_s("F")
187
+ when Time then value.utc.iso8601
188
+ # DateTime subclasses Date, so it must match first.
189
+ when DateTime then value.to_time.utc.iso8601
190
+ when Date then value.iso8601
191
+ when Symbol then value.to_s
192
+ else value
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,216 @@
1
+ require "permittable/version"
2
+ require "permittable/json_schema"
3
+
4
+ module Permittable
5
+ # Assembles OpenAPI 3.1 fragments and documents from Permittable contracts.
6
+ # Plain Ruby over the frozen contract registry — Rails is not required; the
7
+ # `permittable:openapi` rake task (loaded by the Railtie) supplies the
8
+ # Rails-only parts: eager loading, controller discovery, and the route
9
+ # descriptors that turn operations into real `paths` entries.
10
+ #
11
+ # Everything the exporter cannot know is left visible rather than guessed:
12
+ # actions covered only by a catch-all rule on a host without
13
+ # `action_methods` appear under the "*" key with `x-permittable-catch-all`,
14
+ # and operations with no matching route land in `x-permittable-controllers`
15
+ # instead of being dropped silently.
16
+ module OpenAPI
17
+ module_function
18
+
19
+ # The error envelope rendered by render_invalid_parameters (see
20
+ # ErrorEnvelope): code/details are present on every violation this gem
21
+ # raises, message always.
22
+ ERROR_SCHEMA = {
23
+ "type" => "object",
24
+ "properties" => {
25
+ "success" => { "type" => "boolean", "enum" => [false] },
26
+ "error" => {
27
+ "type" => "object",
28
+ "properties" => {
29
+ "message" => { "type" => "string" },
30
+ "code" => { "type" => "string", "enum" => ["invalid_parameters"] },
31
+ "details" => {
32
+ "type" => "array",
33
+ "items" => {
34
+ "type" => "object",
35
+ "properties" => {
36
+ "param" => {
37
+ "type" => "string",
38
+ "description" => "Fully-qualified parameter path, e.g. user.address.zip or line_items[1].sku"
39
+ },
40
+ "code" => {
41
+ "type" => "string",
42
+ "description" => "missing / invalid_type / inclusion / format / length / unknown / invalid, " \
43
+ "or a contract-specific symbol"
44
+ }
45
+ },
46
+ "required" => %w[param code]
47
+ }
48
+ }
49
+ },
50
+ "required" => %w[message]
51
+ }
52
+ },
53
+ "required" => %w[success error]
54
+ }.freeze
55
+
56
+ # Instance methods the concern itself adds to every including controller;
57
+ # action_methods reports them as actions (they are public by design), but
58
+ # they are never routed and must not be documented as endpoints. Resolved
59
+ # lazily — at file-load time the concern's module body may not have run.
60
+ def concern_methods
61
+ @concern_methods ||= Permittable.public_instance_methods(false).map(&:to_s).freeze
62
+ end
63
+
64
+ # Shared `components` for any document referencing Permittable responses.
65
+ def components
66
+ {
67
+ "schemas" => { "PermittableInvalidParameters" => ERROR_SCHEMA },
68
+ "responses" => {
69
+ "PermittableBadRequest" => error_response(
70
+ "The root: key is missing or not an object — the request envelope itself is malformed."
71
+ ),
72
+ "PermittableUnprocessableEntity" => error_response(
73
+ "One or more parameters violated the action's contract; details names each offender."
74
+ )
75
+ }
76
+ }
77
+ end
78
+
79
+ def error_response(description)
80
+ {
81
+ "description" => description,
82
+ "content" => {
83
+ "application/json" => {
84
+ "schema" => { "$ref" => "#/components/schemas/PermittableInvalidParameters" }
85
+ }
86
+ }
87
+ }
88
+ end
89
+
90
+ # OpenAPI requestBody object for the contract covering `action`, nil when
91
+ # no contract does. `required` mirrors the runtime: a rooted contract
92
+ # rejects a bodyless request outright (400), and so does any top-level
93
+ # required field (missing).
94
+ def request_body_for(controller, action)
95
+ rule = controller.permit_rule_for(action)
96
+ rule && rule_request_body(rule)
97
+ end
98
+
99
+ def rule_request_body(rule)
100
+ {
101
+ "required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
102
+ "content" => { "application/json" => { "schema" => JsonSchema.rule(rule) } }
103
+ }
104
+ end
105
+
106
+ # { action => operation } for every action the controller's contracts
107
+ # cover, resolved through permit_rule_for so last-matching-rule-wins holds
108
+ # in the documentation exactly as it does at request time.
109
+ def operations_for(controller)
110
+ documented_actions(controller).to_h { |action| [action, operation_for(controller, action)] }
111
+ end
112
+
113
+ # Explicitly-declared actions in declaration order; when a catch-all rule
114
+ # exists, the controller's remaining action_methods (sorted) follow — or
115
+ # the literal "*" on hosts without action_methods (plain-Ruby params
116
+ # ducks), where the covered action set is unknowable.
117
+ def documented_actions(controller)
118
+ contracts = controller.permittable_contracts
119
+ explicit = contracts.flat_map { |rule| rule[:actions] }.uniq
120
+ return explicit unless contracts.any? { |rule| rule[:actions].empty? }
121
+ return explicit + ["*"] unless controller.respond_to?(:action_methods)
122
+
123
+ explicit + (controller.action_methods.map(&:to_s).sort - explicit - concern_methods)
124
+ end
125
+
126
+ def operation_for(controller, action)
127
+ rule = if action == "*"
128
+ controller.permittable_contracts.reverse_each.find { |r| r[:actions].empty? }
129
+ else
130
+ controller.permit_rule_for(action)
131
+ end
132
+ operation = {}
133
+ key = controller_key(controller)
134
+ operation["operationId"] = "#{key.tr('/', '_')}_#{action}" if key && action != "*"
135
+ operation["description"] = rule[:desc] if rule[:desc]
136
+ operation["requestBody"] = rule_request_body(rule)
137
+ operation["responses"] = responses_for(rule)
138
+ # The docs must not promise a 422 the server doesn't yet send. Only
139
+ # the rule's own declaration is contract data — the app-wide
140
+ # Permittable.mode is runtime configuration the export can't see.
141
+ operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
142
+ operation["x-permittable-catch-all"] = true if action == "*"
143
+ operation
144
+ end
145
+
146
+ def responses_for(rule)
147
+ responses = {}
148
+ responses["400"] = { "$ref" => "#/components/responses/PermittableBadRequest" } if rule[:root]
149
+ responses["422"] = { "$ref" => "#/components/responses/PermittableUnprocessableEntity" }
150
+ responses
151
+ end
152
+
153
+ # A complete OpenAPI 3.1 document. `routes:` is an optional array of
154
+ # { controller:, action:, verb:, path: } descriptors (see rails_routes);
155
+ # operations with a matching descriptor become `paths` entries, the rest
156
+ # are grouped by controller under `x-permittable-controllers`.
157
+ def document(controllers:, info: {}, routes: nil)
158
+ paths = {}
159
+ unrouted = {}
160
+ controllers.each do |controller|
161
+ operations = operations_for(controller)
162
+ next if operations.empty?
163
+
164
+ place_operations(controller, operations, routes, paths, unrouted)
165
+ end
166
+ doc = {
167
+ "openapi" => "3.1.0",
168
+ "info" => { "title" => "Permittable contracts", "version" => VERSION }.merge(info),
169
+ "paths" => paths,
170
+ "components" => components
171
+ }
172
+ doc["x-permittable-controllers"] = unrouted unless unrouted.empty?
173
+ doc
174
+ end
175
+
176
+ def place_operations(controller, operations, routes, paths, unrouted)
177
+ key = controller_key(controller) || controller.inspect
178
+ operations.each do |action, operation|
179
+ matched = routes_for(routes, key, action)
180
+ if matched.empty?
181
+ (unrouted[key] ||= {})[action] = operation
182
+ else
183
+ matched.each { |route| (paths[route[:path]] ||= {})[route[:verb].to_s.downcase] = operation }
184
+ end
185
+ end
186
+ end
187
+
188
+ def routes_for(routes, controller_key, action)
189
+ return [] if routes.nil? || action == "*"
190
+
191
+ routes.select { |r| r[:controller].to_s == controller_key && r[:action].to_s == action }
192
+ end
193
+
194
+ # { controller:, action:, verb:, path: } descriptors from a Rails
195
+ # application's route set. Duck-typed against Journey routes (each one
196
+ # responds to requirements / verb / path.spec) so it stays unit-testable
197
+ # without Rails; Rails path params (:id) become OpenAPI templates ({id}).
198
+ def rails_routes(app)
199
+ app.routes.routes.filter_map do |route|
200
+ requirements = route.requirements
201
+ verb = route.verb.to_s
202
+ next if requirements[:controller].nil? || requirements[:action].nil? || verb.empty?
203
+
204
+ path = route.path.spec.to_s.sub("(.:format)", "").gsub(/:(\w+)/) { "{#{Regexp.last_match(1)}}" }
205
+ { controller: requirements[:controller], action: requirements[:action],
206
+ verb: verb.split("|").first.downcase, path: path }
207
+ end
208
+ end
209
+
210
+ def controller_key(controller)
211
+ return controller.controller_path if controller.respond_to?(:controller_path)
212
+
213
+ controller.name
214
+ end
215
+ end
216
+ end
@@ -12,5 +12,9 @@ module Permittable
12
12
  filter = ::Permittable.filter_parameter_registry.to_proc
13
13
  app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter)
14
14
  end
15
+
16
+ rake_tasks do
17
+ load File.expand_path("tasks/openapi.rake", __dir__)
18
+ end
15
19
  end
16
20
  end
@@ -0,0 +1,44 @@
1
+ # Exports every Permittable contract in the app as an OpenAPI 3.1 document —
2
+ # the docs-that-cannot-drift counterpart to the schema-drift guard. Eager
3
+ # loading makes every controller's permit_params macro run (also exercising
4
+ # the drift guard), then the route set maps documented actions onto paths.
5
+ #
6
+ # bin/rails permittable:openapi # JSON to stdout
7
+ # bin/rails "permittable:openapi[openapi/api.json]" # write to a file
8
+ #
9
+ # OPENAPI_TITLE / OPENAPI_VERSION override the document's info block.
10
+ require "json"
11
+ require "fileutils"
12
+
13
+ namespace :permittable do
14
+ desc "Export an OpenAPI 3.1 document generated from every Permittable contract"
15
+ task :openapi, [:output] => :environment do |_t, task_args|
16
+ Rails.application.eager_load!
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|
22
+ controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any?
23
+ end
24
+
25
+ document = Permittable::OpenAPI.document(
26
+ controllers: controllers,
27
+ routes: Permittable::OpenAPI.rails_routes(Rails.application),
28
+ info: {
29
+ "title" => ENV.fetch("OPENAPI_TITLE") { "#{Rails.application.class.module_parent_name} API" },
30
+ "version" => ENV.fetch("OPENAPI_VERSION", "1.0.0")
31
+ }
32
+ )
33
+
34
+ json = "#{JSON.pretty_generate(document)}\n"
35
+ if task_args[:output]
36
+ FileUtils.mkdir_p(File.dirname(task_args[:output]))
37
+ File.write(task_args[:output], json)
38
+ puts "Permittable: wrote #{task_args[:output]} " \
39
+ "(#{controllers.length} controller#{'s' unless controllers.length == 1})"
40
+ else
41
+ puts json
42
+ end
43
+ end
44
+ end
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.2.0".freeze
2
+ VERSION = "0.4.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -63,6 +63,21 @@ require "permittable/filter_parameter_registry"
63
63
  # action that never reads params never pays. `enforce: true` installs the
64
64
  # check as a before_action instead (reject before the action body runs).
65
65
  #
66
+ # MONITOR MODE — the rollout switch. `mode: :monitor` on a rule (or
67
+ # `Permittable.mode = :monitor` app-wide; a rule's own mode: wins) runs the
68
+ # full pipeline but REPORTS violations instead of rejecting: the same
69
+ # "invalid_parameters.permittable" event fires (payload mode: :monitor),
70
+ # the logger warns, and permitted_params returns the raw params passed
71
+ # through untouched — no casts, no defaults, no transforms — so behaviour
72
+ # is identical to the pre-contract app (a missing root: passes an empty
73
+ # hash; a rootless contract drops only the router's bookkeeping keys).
74
+ # Monitor rules validate eagerly in the before_action regardless of
75
+ # enforce:, because telemetry must not depend on the action calling
76
+ # permitted_params — legacy actions still reading `params` directly are
77
+ # exactly the ones being monitored — and monitoring can never halt the
78
+ # request. `permittable_violations` reads the recorded details ([] when
79
+ # the request was clean).
80
+ #
66
81
  # Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
67
82
  # casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
68
83
  # and silently corrupting untrusted input is exactly what a contract must not
@@ -119,6 +134,7 @@ module Permittable
119
134
  LABEL = "Permittable".freeze
120
135
  SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
121
136
  UNKNOWN_MODES = %i[ignore log error].freeze
137
+ MODES = %i[enforce monitor].freeze
122
138
  # Rails merges routing bookkeeping into params; a top-level (root: false)
123
139
  # unknown-keys check must not flag them.
124
140
  ROUTING_KEYS = %w[controller action format].freeze
@@ -144,6 +160,26 @@ module Permittable
144
160
  end
145
161
 
146
162
  attr_writer :filter_parameter_registry
163
+
164
+ # App-wide default for rules that don't declare their own mode:.
165
+ # :enforce (the default) rejects violating requests; :monitor reports
166
+ # them — same instrumentation event with payload mode: :monitor, plus a
167
+ # logger.warn — and lets the request proceed with the raw params passed
168
+ # through. This is the rollout switch for brownfield adoption: set it
169
+ # from an initializer (Permittable.mode =
170
+ # ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym) and flip controllers
171
+ # to their final mode one at a time, since a rule's own mode: always
172
+ # wins over this default.
173
+ def mode
174
+ @mode || :enforce
175
+ end
176
+
177
+ def mode=(value)
178
+ value = value.to_sym
179
+ raise ArgumentError, "#{LABEL}: mode must be one of #{MODES.join(', ')}" unless MODES.include?(value)
180
+
181
+ @mode = value
182
+ end
147
183
  end
148
184
 
149
185
  # Raised when the request violates the matching contract. `details` is an
@@ -313,9 +349,9 @@ module Permittable
313
349
  # declaration is validated eagerly: a bad contract is a programmer error and
314
350
  # should fail at class load, not at request time.
315
351
  class ContractBuilder
316
- SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message].freeze
317
- NESTED_OPTS = %i[virtual sensitive message].freeze
318
- ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message].freeze
352
+ SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example].freeze
353
+ NESTED_OPTS = %i[virtual sensitive message desc].freeze
354
+ ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example].freeze
319
355
 
320
356
  attr_reader :finalizer
321
357
 
@@ -368,7 +404,8 @@ module Permittable
368
404
  validate_length!(name, field[:length]) if field.key?(:length)
369
405
  validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
370
406
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
371
- validate_array_default!(field) if field.key?(:default)
407
+ validate_array_authored_value!(field, :default) if field.key?(:default)
408
+ validate_array_authored_value!(field, :example) if field.key?(:example)
372
409
  validate_message!(field)
373
410
  @fields << field
374
411
  end
@@ -442,7 +479,8 @@ module Permittable
442
479
  validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
443
480
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
444
481
  resolve_normalizer!(field)
445
- validate_default!(field)
482
+ validate_authored_value!(field, :default)
483
+ validate_authored_value!(field, :example)
446
484
  validate_message!(field)
447
485
  end
448
486
 
@@ -481,27 +519,28 @@ module Permittable
481
519
  end
482
520
  end
483
521
 
484
- # A default must satisfy the field's own contract — catching a bad
485
- # default at class load beats shipping it to every request.
486
- def validate_default!(field)
487
- return unless field.key?(:default)
522
+ # An authored value (`default:`, or a documentation `example:`) must
523
+ # satisfy the field's own contract catching a lie at class load beats
524
+ # shipping it to every request (or publishing it in generated docs).
525
+ def validate_authored_value!(field, opt)
526
+ return unless field.key?(opt)
488
527
 
489
- status, code = Coercion.check_scalar(field, field[:default])
528
+ status, code = Coercion.check_scalar(field, field[opt])
490
529
  return if status == :ok
491
530
 
492
- raise ArgumentError, "#{LABEL}: :default for field :#{field[:name]} violates its own contract (#{code})"
531
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
493
532
  end
494
533
 
495
- def validate_array_default!(field)
496
- default = field[:default]
497
- raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} must be an Array" unless default.is_a?(Array)
534
+ def validate_array_authored_value!(field, opt)
535
+ value = field[opt]
536
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
498
537
  return unless field[:of]
499
538
 
500
- default.each do |element|
539
+ value.each do |element|
501
540
  status, code = Coercion.cast(field[:of], element)
502
541
  next if status == :ok
503
542
 
504
- raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
543
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
505
544
  end
506
545
  end
507
546
 
@@ -561,12 +600,23 @@ module Permittable
561
600
  # undeclared keys, at every nesting level.
562
601
  # enforce: false (default) validates lazily on the first
563
602
  # permitted_params call; true validates in a before_action.
564
- def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &block)
603
+ # mode: nil (default) follows Permittable.mode; :enforce rejects
604
+ # violating requests; :monitor reports them and passes the
605
+ # raw params through (see MONITOR MODE in the module
606
+ # comment).
607
+ # desc: documentation only — carried on the rule for exporters
608
+ # (Permittable::OpenAPI); the runtime never reads it.
609
+ def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &block)
565
610
  raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
566
611
 
567
612
  unknown = unknown.to_sym
568
613
  raise ArgumentError, "#{LABEL}: :unknown must be one of #{UNKNOWN_MODES.join(', ')}" unless UNKNOWN_MODES.include?(unknown)
569
614
 
615
+ mode = mode&.to_sym
616
+ if mode && !MODES.include?(mode)
617
+ raise ArgumentError, "#{LABEL}: :mode must be one of #{MODES.join(', ')}, or nil to follow Permittable.mode"
618
+ end
619
+
570
620
  builder = ContractBuilder.new
571
621
  fields = builder.build(&block)
572
622
  raise ArgumentError, "#{LABEL}: a contract must declare at least one field" if fields.empty?
@@ -576,8 +626,8 @@ module Permittable
576
626
  register_sensitive_params(fields)
577
627
 
578
628
  rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
579
- model: model_class, unknown: unknown, enforce: !!enforce, fields: fields,
580
- finalize: builder.finalizer }.freeze
629
+ model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
630
+ finalize: builder.finalizer, desc: desc }.freeze
581
631
  self.permittable_contracts = permittable_contracts + [rule]
582
632
  end
583
633
 
@@ -652,21 +702,44 @@ module Permittable
652
702
  rule = self.class.permit_rule_for(action)
653
703
  raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
654
704
 
655
- @permittable_validated[action] = validate_params_contract!(rule)
705
+ @permittable_validated[action] = validate_params_contract!(rule, action)
656
706
  end
657
707
 
658
708
  # before_action entry point (public so hosts can `skip_before_action
659
- # :enforce_params_contract`). Only rules that opted in with
660
- # `enforce: true` validate here.
709
+ # :enforce_params_contract`). Two kinds of rule validate here: those that
710
+ # opted in with `enforce: true`, and monitor-mode rules — monitoring must
711
+ # not depend on the action calling permitted_params (legacy actions still
712
+ # reading `params` directly are exactly the ones being monitored), and it
713
+ # can never halt the request because monitor mode never raises.
661
714
  def enforce_params_contract
662
715
  action = permittable_action_name
663
716
  return nil unless action
664
717
 
665
718
  rule = self.class.permit_rule_for(action)
666
- permitted_params(action) if rule && rule[:enforce]
719
+ permitted_params(action) if rule && (rule[:enforce] || permittable_mode(rule) == :monitor)
667
720
  nil
668
721
  end
669
722
 
723
+ # The violation details recorded by validating `action` (default: the
724
+ # current action) — [] when the request satisfied the contract. Triggers
725
+ # the same memoized validation as permitted_params, so under monitor mode
726
+ # this is the request-level observable ("what would have been
727
+ # rejected?"); under enforce mode it swallows the raise and hands back
728
+ # the details, which makes "would this request fail?" a one-liner in
729
+ # tests.
730
+ def permittable_violations(action = nil)
731
+ action = (action || permittable_action_name).to_s
732
+ @permittable_violations ||= {}
733
+ unless @permittable_violations.key?(action)
734
+ begin
735
+ permitted_params(action)
736
+ rescue InvalidParameters
737
+ # validation recorded the details before raising
738
+ end
739
+ end
740
+ @permittable_violations.fetch(action)
741
+ end
742
+
670
743
  # rescue_from target — renders through the shared envelope (the host's
671
744
  # render_error when present, the identical inline shape otherwise).
672
745
  def render_invalid_parameters(error)
@@ -678,7 +751,7 @@ module Permittable
678
751
 
679
752
  private
680
753
 
681
- def validate_params_contract!(rule)
754
+ def validate_params_contract!(rule, action)
682
755
  violations = []
683
756
  source = permittable_root_hash(rule, violations)
684
757
  result = ActiveSupport::HashWithIndifferentAccess.new
@@ -688,19 +761,53 @@ module Permittable
688
761
  end
689
762
  # finalize only sees a hash every field vouched for — never garbage.
690
763
  result = permittable_run_finalize(rule[:finalize], result, violations) if violations.empty? && rule[:finalize]
764
+ violations.each(&:freeze)
765
+ (@permittable_violations ||= {})[action] = violations.freeze
691
766
  return result if violations.empty?
767
+ return permittable_monitor_pass_through(rule, source, violations) if permittable_mode(rule) == :monitor
692
768
 
693
769
  raise_invalid_parameters!(violations, status: source ? :unprocessable_entity : :bad_request)
694
770
  end
695
771
 
772
+ # A rule's own mode: wins; otherwise the app-wide Permittable.mode.
773
+ def permittable_mode(rule)
774
+ rule[:mode] || Permittable.mode
775
+ end
776
+
777
+ # Monitor mode's violation path: emit the same instrumentation event the
778
+ # enforce path does (payload mode: :monitor) plus a warn line, then hand
779
+ # back exactly what the client sent — no casts, no defaults, no
780
+ # transforms — so behaviour is identical to the pre-contract app. A
781
+ # missing root: passes an empty hash through (the envelope you asked for
782
+ # isn't there); a rootless contract drops only the router's bookkeeping
783
+ # keys, mirroring their exemption from the unknown-keys check.
784
+ def permittable_monitor_pass_through(rule, source, violations)
785
+ permittable_instrument_violations(violations, mode: :monitor)
786
+ if respond_to?(:logger) && logger
787
+ logger.warn("#{LABEL}: [monitor] ##{permittable_action_name} would have been rejected: " \
788
+ "#{permittable_violation_summary(violations)}")
789
+ end
790
+ return ActiveSupport::HashWithIndifferentAccess.new unless source
791
+
792
+ passed = ActiveSupport::HashWithIndifferentAccess.new(source)
793
+ rule[:root] ? passed : passed.except(*ROUTING_KEYS)
794
+ end
795
+
696
796
  def raise_invalid_parameters!(violations, status:)
697
- violations.each(&:freeze)
797
+ permittable_instrument_violations(violations, mode: :enforce)
798
+ raise InvalidParameters.new("Invalid parameters: #{permittable_violation_summary(violations)}",
799
+ details: violations, status: status)
800
+ end
801
+
802
+ def permittable_instrument_violations(violations, mode:)
698
803
  ActiveSupport::Notifications.instrument(
699
804
  "invalid_parameters.permittable",
700
- controller: permittable_controller_name, action: permittable_action_name, details: violations
805
+ controller: permittable_controller_name, action: permittable_action_name, details: violations, mode: mode
701
806
  )
702
- summary = violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
703
- raise InvalidParameters.new("Invalid parameters: #{summary}", details: violations, status: status)
807
+ end
808
+
809
+ def permittable_violation_summary(violations)
810
+ violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
704
811
  end
705
812
 
706
813
  # One violation detail entry. A field's `message:` (String, or Hash keyed
@@ -873,5 +980,11 @@ module Permittable
873
980
  end
874
981
  end
875
982
 
876
- # Boot-time integration (filter_parameters registration), Rails apps only
983
+ # Contract exporters the other readers of the frozen contract registry.
984
+ # Loaded after the module body so OpenAPI can see the concern's own methods.
985
+ require "permittable/json_schema"
986
+ require "permittable/open_api"
987
+
988
+ # Boot-time integration (filter_parameters registration, the
989
+ # permittable:openapi rake task), Rails apps only
877
990
  require "permittable/railtie" if defined?(Rails::Railtie)
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.2.0
4
+ version: 0.4.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-08-18 00:00:00.000000000 Z
11
+ date: 2026-09-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -50,7 +50,10 @@ files:
50
50
  - lib/permittable/column_guard.rb
51
51
  - lib/permittable/error_envelope.rb
52
52
  - lib/permittable/filter_parameter_registry.rb
53
+ - lib/permittable/json_schema.rb
54
+ - lib/permittable/open_api.rb
53
55
  - lib/permittable/railtie.rb
56
+ - lib/permittable/tasks/openapi.rake
54
57
  - lib/permittable/version.rb
55
58
  homepage: https://github.com/VSN2015/permittable
56
59
  licenses: