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.
@@ -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.1.2".freeze
2
+ VERSION = "0.3.0".freeze
3
3
  end