permittable 0.1.2 → 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 +20 -0
- data/README.md +417 -46
- 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 +98 -34
- metadata +5 -2
|
@@ -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