permittable 0.2.0 → 0.3.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: aa679480fdac311694469096c7537926586e987ce2e7a724099611ec91dd3d2b
4
+ data.tar.gz: fa1785c674f0cbf388ce9f74b87f22344adef95cd5a5bfa6c81490fb8958a124
5
5
  SHA512:
6
- metadata.gz: bd955e930e66036f2bffd9f006996ad0713e3582eda8386e51bc668d42432db7f941bef9724ba5c6589e85be4ce91827cc8536f49ca6c0d698562de0aee65520
7
- data.tar.gz: a61f63950413801e405a6690e141a35616d974cc347bdae86e9a6f239a75e89225604b973acb4daf4b7daa25492e767175f674de6376df62eb0128e27f16fa62
6
+ metadata.gz: 66e7a85dad6e8029dbc8827b1a55ac14a297e3dc006315ffc66efc0e2f7dd6179ee6247e33f5de93a06d7fe08c3ff1205e30bf6dde6b16d93bfd381bdd3da813
7
+ data.tar.gz: fc0e4a920d62a687c960b808301841327bde86c85a2cf90c36c65e0ac61f133844e0733cd6ad3075298a8680d5a1360e9319495c4e60a9382449047bf6d76c0e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.3.0 (2026-08-24)
4
+ <!-- title: OpenAPI export -->
5
+
6
+ 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.
7
+
8
+ ### Added
9
+ - **`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.
10
+ - **`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.
11
+ - **`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.
12
+ - **`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.
13
+
3
14
  ## 0.2.0 (2026-08-18)
4
15
  <!-- title: custom error messages -->
5
16
 
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
+ - [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,7 @@ 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 | ❌ | ❌ | ✅ |
73
75
 
74
76
  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
77
 
@@ -109,7 +111,7 @@ Validation is **lazy by default**: it runs on the first `permitted_params` call,
109
111
  ## Declaring a contract
110
112
 
111
113
  ```ruby
112
- permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &contract)
114
+ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &contract)
113
115
  ```
114
116
 
115
117
  | Option | Default | Meaning |
@@ -119,6 +121,7 @@ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: fals
119
121
  | `model:` | `nil` | Model class, or `true` to infer from `controller_name`, enabling the [drift guard](#the-schema-drift-guard) |
120
122
  | `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — how to treat undeclared keys |
121
123
  | `enforce:` | `false` | `false` validates lazily on first use; `true` validates in a `before_action` |
124
+ | `desc:` | `nil` | Documentation only — becomes the operation description in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
122
125
 
123
126
  `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
127
 
@@ -186,6 +189,8 @@ Which options are legal depends on the field kind — anything else raises at cl
186
189
  | `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
190
  | `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
188
191
  | `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
192
+ | `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
193
+ | `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
189
194
 
190
195
  ¹ `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
196
 
@@ -378,6 +383,49 @@ ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*,
378
383
  end
379
384
  ```
380
385
 
386
+ ## Exporting OpenAPI (docs that cannot drift)
387
+
388
+ 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:
389
+
390
+ ```sh
391
+ bin/rails permittable:openapi # JSON to stdout
392
+ bin/rails "permittable:openapi[openapi/api.json]" # write to a file
393
+ ```
394
+
395
+ 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.
396
+
397
+ Or build fragments programmatically — no Rails required:
398
+
399
+ ```ruby
400
+ Permittable::JsonSchema.rule(UsersController.permit_rule_for(:create)) # request-body schema
401
+ Permittable::OpenAPI.request_body_for(UsersController, :create) # OpenAPI requestBody object
402
+ Permittable::OpenAPI.operations_for(UsersController) # { action => operation }
403
+ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
404
+ ```
405
+
406
+ How contracts map:
407
+
408
+ | Contract | Emitted schema |
409
+ |---|---|
410
+ | `required` / `optional` | the object's `required:` array; required strings also get `minLength: 1` (`""` is absent) |
411
+ | `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
412
+ | `:date` / `:datetime` | `string` + `format: date` / `date-time` |
413
+ | `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
414
+ | `in:` Array / numeric Range | `enum` / `minimum` + `maximum` (exclusive ends honoured) |
415
+ | `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
416
+ | `format:` | `pattern`, with `\A`/`\z` translated to `^`/`$` |
417
+ | `default:` / `desc:` / `example:` | `default` / `description` / `examples` |
418
+ | nested block / `array` | `object` + `properties` / `array` + `items` |
419
+ | `unknown: :error` | `additionalProperties: false`, at every nesting level |
420
+ | `root:` | the wrapping object, itself required |
421
+ | `sensitive: true` | `writeOnly: true` (never echoed in responses) |
422
+
423
+ 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.
424
+
425
+ **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 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.
426
+
427
+ 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.
428
+
381
429
  ## API reference
382
430
 
383
431
  ### Instance methods
@@ -403,6 +451,8 @@ end
403
451
  | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
404
452
  | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
405
453
  | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
454
+ | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
455
+ | `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
406
456
 
407
457
  ## Errors caught at class load
408
458
 
@@ -415,7 +465,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
415
465
  - `format:`, `length:`, or `normalize:` on a non-`:string` field
416
466
  - `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
417
467
  - `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:`
468
+ - A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
419
469
  - `required: true` combined with `default:`
420
470
  - A field given both a type and a nested block; an array given both `of:` and a block
421
471
  - An empty contract, or a nested block declaring no sub-fields
@@ -438,7 +488,7 @@ Using [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `Concer
438
488
 
439
489
  ```sh
440
490
  bundle install
441
- bundle exec rspec # 76 examples
491
+ bundle exec rspec # 112 examples
442
492
  bundle exec rubocop
443
493
  ```
444
494
 
@@ -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,212 @@
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
+ operation["x-permittable-catch-all"] = true if action == "*"
139
+ operation
140
+ end
141
+
142
+ def responses_for(rule)
143
+ responses = {}
144
+ responses["400"] = { "$ref" => "#/components/responses/PermittableBadRequest" } if rule[:root]
145
+ responses["422"] = { "$ref" => "#/components/responses/PermittableUnprocessableEntity" }
146
+ responses
147
+ end
148
+
149
+ # A complete OpenAPI 3.1 document. `routes:` is an optional array of
150
+ # { controller:, action:, verb:, path: } descriptors (see rails_routes);
151
+ # operations with a matching descriptor become `paths` entries, the rest
152
+ # are grouped by controller under `x-permittable-controllers`.
153
+ def document(controllers:, info: {}, routes: nil)
154
+ paths = {}
155
+ unrouted = {}
156
+ controllers.each do |controller|
157
+ operations = operations_for(controller)
158
+ next if operations.empty?
159
+
160
+ place_operations(controller, operations, routes, paths, unrouted)
161
+ end
162
+ doc = {
163
+ "openapi" => "3.1.0",
164
+ "info" => { "title" => "Permittable contracts", "version" => VERSION }.merge(info),
165
+ "paths" => paths,
166
+ "components" => components
167
+ }
168
+ doc["x-permittable-controllers"] = unrouted unless unrouted.empty?
169
+ doc
170
+ end
171
+
172
+ def place_operations(controller, operations, routes, paths, unrouted)
173
+ key = controller_key(controller) || controller.inspect
174
+ operations.each do |action, operation|
175
+ matched = routes_for(routes, key, action)
176
+ if matched.empty?
177
+ (unrouted[key] ||= {})[action] = operation
178
+ else
179
+ matched.each { |route| (paths[route[:path]] ||= {})[route[:verb].to_s.downcase] = operation }
180
+ end
181
+ end
182
+ end
183
+
184
+ def routes_for(routes, controller_key, action)
185
+ return [] if routes.nil? || action == "*"
186
+
187
+ routes.select { |r| r[:controller].to_s == controller_key && r[:action].to_s == action }
188
+ end
189
+
190
+ # { controller:, action:, verb:, path: } descriptors from a Rails
191
+ # application's route set. Duck-typed against Journey routes (each one
192
+ # responds to requirements / verb / path.spec) so it stays unit-testable
193
+ # without Rails; Rails path params (:id) become OpenAPI templates ({id}).
194
+ def rails_routes(app)
195
+ app.routes.routes.filter_map do |route|
196
+ requirements = route.requirements
197
+ verb = route.verb.to_s
198
+ next if requirements[:controller].nil? || requirements[:action].nil? || verb.empty?
199
+
200
+ path = route.path.spec.to_s.sub("(.:format)", "").gsub(/:(\w+)/) { "{#{Regexp.last_match(1)}}" }
201
+ { controller: requirements[:controller], action: requirements[:action],
202
+ verb: verb.split("|").first.downcase, path: path }
203
+ end
204
+ end
205
+
206
+ def controller_key(controller)
207
+ return controller.controller_path if controller.respond_to?(:controller_path)
208
+
209
+ controller.name
210
+ end
211
+ end
212
+ 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.3.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -313,9 +313,9 @@ module Permittable
313
313
  # declaration is validated eagerly: a bad contract is a programmer error and
314
314
  # should fail at class load, not at request time.
315
315
  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
316
+ SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example].freeze
317
+ NESTED_OPTS = %i[virtual sensitive message desc].freeze
318
+ ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example].freeze
319
319
 
320
320
  attr_reader :finalizer
321
321
 
@@ -368,7 +368,8 @@ module Permittable
368
368
  validate_length!(name, field[:length]) if field.key?(:length)
369
369
  validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
370
370
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
371
- validate_array_default!(field) if field.key?(:default)
371
+ validate_array_authored_value!(field, :default) if field.key?(:default)
372
+ validate_array_authored_value!(field, :example) if field.key?(:example)
372
373
  validate_message!(field)
373
374
  @fields << field
374
375
  end
@@ -442,7 +443,8 @@ module Permittable
442
443
  validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
443
444
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
444
445
  resolve_normalizer!(field)
445
- validate_default!(field)
446
+ validate_authored_value!(field, :default)
447
+ validate_authored_value!(field, :example)
446
448
  validate_message!(field)
447
449
  end
448
450
 
@@ -481,27 +483,28 @@ module Permittable
481
483
  end
482
484
  end
483
485
 
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)
486
+ # An authored value (`default:`, or a documentation `example:`) must
487
+ # satisfy the field's own contract catching a lie at class load beats
488
+ # shipping it to every request (or publishing it in generated docs).
489
+ def validate_authored_value!(field, opt)
490
+ return unless field.key?(opt)
488
491
 
489
- status, code = Coercion.check_scalar(field, field[:default])
492
+ status, code = Coercion.check_scalar(field, field[opt])
490
493
  return if status == :ok
491
494
 
492
- raise ArgumentError, "#{LABEL}: :default for field :#{field[:name]} violates its own contract (#{code})"
495
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
493
496
  end
494
497
 
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)
498
+ def validate_array_authored_value!(field, opt)
499
+ value = field[opt]
500
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
498
501
  return unless field[:of]
499
502
 
500
- default.each do |element|
503
+ value.each do |element|
501
504
  status, code = Coercion.cast(field[:of], element)
502
505
  next if status == :ok
503
506
 
504
- raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
507
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
505
508
  end
506
509
  end
507
510
 
@@ -561,7 +564,9 @@ module Permittable
561
564
  # undeclared keys, at every nesting level.
562
565
  # enforce: false (default) validates lazily on the first
563
566
  # permitted_params call; true validates in a before_action.
564
- def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &block)
567
+ # desc: documentation only carried on the rule for exporters
568
+ # (Permittable::OpenAPI); the runtime never reads it.
569
+ def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &block)
565
570
  raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
566
571
 
567
572
  unknown = unknown.to_sym
@@ -577,7 +582,7 @@ module Permittable
577
582
 
578
583
  rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
579
584
  model: model_class, unknown: unknown, enforce: !!enforce, fields: fields,
580
- finalize: builder.finalizer }.freeze
585
+ finalize: builder.finalizer, desc: desc }.freeze
581
586
  self.permittable_contracts = permittable_contracts + [rule]
582
587
  end
583
588
 
@@ -873,5 +878,11 @@ module Permittable
873
878
  end
874
879
  end
875
880
 
876
- # Boot-time integration (filter_parameters registration), Rails apps only
881
+ # Contract exporters the other readers of the frozen contract registry.
882
+ # Loaded after the module body so OpenAPI can see the concern's own methods.
883
+ require "permittable/json_schema"
884
+ require "permittable/open_api"
885
+
886
+ # Boot-time integration (filter_parameters registration, the
887
+ # permittable:openapi rake task), Rails apps only
877
888
  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.3.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: