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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +53 -3
- data/lib/permittable/json_schema.rb +196 -0
- data/lib/permittable/open_api.rb +212 -0
- data/lib/permittable/railtie.rb +4 -0
- data/lib/permittable/tasks/openapi.rake +44 -0
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +30 -19
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa679480fdac311694469096c7537926586e987ce2e7a724099611ec91dd3d2b
|
|
4
|
+
data.tar.gz: fa1785c674f0cbf388ce9f74b87f22344adef95cd5a5bfa6c81490fb8958a124
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 #
|
|
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
|
data/lib/permittable/railtie.rb
CHANGED
|
@@ -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
|
data/lib/permittable/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
485
|
-
#
|
|
486
|
-
|
|
487
|
-
|
|
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[
|
|
492
|
+
status, code = Coercion.check_scalar(field, field[opt])
|
|
490
493
|
return if status == :ok
|
|
491
494
|
|
|
492
|
-
raise ArgumentError, "#{LABEL}:
|
|
495
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
|
|
493
496
|
end
|
|
494
497
|
|
|
495
|
-
def
|
|
496
|
-
|
|
497
|
-
raise ArgumentError, "#{LABEL}:
|
|
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
|
-
|
|
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}:
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
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-
|
|
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:
|