permittable 0.13.1 → 0.15.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 +43 -0
- data/README.md +34 -11
- data/lib/permittable/audit.rb +64 -7
- data/lib/permittable/authored_values.rb +23 -11
- data/lib/permittable/column_guard.rb +133 -12
- data/lib/permittable/contract.rb +4 -1
- data/lib/permittable/error_envelope.rb +21 -4
- data/lib/permittable/json_schema.rb +9 -3
- data/lib/permittable/open_api.rb +213 -11
- data/lib/permittable/rspec.rb +193 -12
- data/lib/permittable/tasks/audit.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +349 -24
- metadata +2 -2
|
@@ -48,12 +48,11 @@ module Permittable
|
|
|
48
48
|
return false unless schema_reachable?(klass)
|
|
49
49
|
|
|
50
50
|
fields.flatten.compact.each do |field|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
column = backing_column(klass, field)
|
|
52
|
+
unless klass.column_names.include?(column)
|
|
53
|
+
raise ArgumentError, ignored_column_message(label, klass, column) if ignored_column?(klass, column)
|
|
53
54
|
|
|
54
|
-
raise ArgumentError,
|
|
55
|
-
"#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
|
|
56
|
-
"#{column_migration_hint(klass, field, types)}"
|
|
55
|
+
raise ArgumentError, missing_column_message(label, klass, field, column, types)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
ensure_column_type!(label, klass, field, types, allowed, transformed: transformed) if check_types
|
|
@@ -61,6 +60,114 @@ module Permittable
|
|
|
61
60
|
true
|
|
62
61
|
end
|
|
63
62
|
|
|
63
|
+
# `alias_attribute :title, :name` makes `title` an attribute assignment
|
|
64
|
+
# accepts, backed by `name`'s column, so the existence check, the
|
|
65
|
+
# ignored_columns check and the type check all look at the target. Aliases
|
|
66
|
+
# may chain; the walk stops at a name it has seen, so a cycle cannot hang a
|
|
67
|
+
# class body. A duck-typed model without `attribute_aliases` (or whose
|
|
68
|
+
# reader returns nil) has none.
|
|
69
|
+
def resolve_alias(klass, field)
|
|
70
|
+
name = field.to_s
|
|
71
|
+
aliases = attribute_aliases_of(klass)
|
|
72
|
+
seen = [name]
|
|
73
|
+
while (target = aliases[name])
|
|
74
|
+
break if seen.include?(target.to_s)
|
|
75
|
+
|
|
76
|
+
name = target.to_s
|
|
77
|
+
seen << name
|
|
78
|
+
end
|
|
79
|
+
name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def attribute_aliases_of(klass)
|
|
83
|
+
klass.respond_to?(:attribute_aliases) ? Hash(klass.attribute_aliases) : {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The column a field is stored in: its alias target's, or for a store
|
|
87
|
+
# accessor (`store_accessor :settings, :theme`) the column of its store,
|
|
88
|
+
# `settings`, itself resolved when the store is an alias. The field is then
|
|
89
|
+
# a key inside that column, not a column of its own, so the column's type
|
|
90
|
+
# says nothing about it (see ensure_column_type!). The guard accepts it for
|
|
91
|
+
# the reason it accepts an alias or an enum: assignment takes it and the
|
|
92
|
+
# data lands in a column. `virtual: true` stays the opt-out for what no
|
|
93
|
+
# column holds. That is also why the store column itself must exist.
|
|
94
|
+
def backing_column(klass, field)
|
|
95
|
+
name = resolve_alias(klass, field)
|
|
96
|
+
store = stored_attribute_store(klass, name)
|
|
97
|
+
store ? resolve_alias(klass, store) : name
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The store (the column holding the hash) a `store_accessor` field lives
|
|
101
|
+
# in, nil when `name` is a plain attribute. A real column of the same name
|
|
102
|
+
# wins: it is the attribute Rails reads. A duck-typed model without
|
|
103
|
+
# `stored_attributes` has none.
|
|
104
|
+
#
|
|
105
|
+
# `stored_attributes` lists the raw keys (`{settings: [:theme]}`), but the
|
|
106
|
+
# accessor is named by `prefix:`/`suffix:` too (`settings_theme`), so the
|
|
107
|
+
# name is accepted only when the model really has a store accessor's writer
|
|
108
|
+
# under it, which is what assignment calls (see store_accessor_writer?). A
|
|
109
|
+
# writer from anywhere else is an ordinary attribute, not a
|
|
110
|
+
# store key. The store is then the one whose key the name is built from,
|
|
111
|
+
# the longest key first and one whose name the accessor mentions next.
|
|
112
|
+
# Rails does not record which prefix built which accessor, so two stores
|
|
113
|
+
# sharing a key are ambiguous, and the guess may check the other's column.
|
|
114
|
+
def stored_attribute_store(klass, name)
|
|
115
|
+
return unless klass.respond_to?(:stored_attributes)
|
|
116
|
+
return if klass.column_names.include?(name) || !store_accessor_writer?(klass, "#{name}=")
|
|
117
|
+
|
|
118
|
+
pairs = Hash(klass.stored_attributes).flat_map { |store, keys| keys.map { |key| [store, key.to_s] } }
|
|
119
|
+
pairs.select { |_store, key| name.include?(key) }
|
|
120
|
+
.min_by { |store, key| [-key.length, name.include?(store.to_s) ? 0 : 1] }&.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Whether `writer` is a store accessor's, whatever the model has layered on
|
|
124
|
+
# it. The accessor is defined by active_record/store.rb itself, so the
|
|
125
|
+
# method chain is walked through `super` (an override in the model, a
|
|
126
|
+
# subclass, a prepended or later-included module calls through to it) until
|
|
127
|
+
# a method defined in that file turns up. That holds across Rails versions:
|
|
128
|
+
# the module holding the accessors is `_store_accessors_module` on 6.1
|
|
129
|
+
# through 8.1 and a `GeneratedStoreMethods` constant on main, which a
|
|
130
|
+
# lookup of either would miss. An ordinary writer (`attr_accessor`, an
|
|
131
|
+
# attribute-API or column writer) is defined elsewhere.
|
|
132
|
+
STORE_SOURCE = %r{/active_record/store\.rb\z}
|
|
133
|
+
|
|
134
|
+
def store_accessor_writer?(klass, writer)
|
|
135
|
+
return false unless klass.method_defined?(writer)
|
|
136
|
+
|
|
137
|
+
method = klass.instance_method(writer)
|
|
138
|
+
while method
|
|
139
|
+
return true if method.source_location&.first.to_s.match?(STORE_SOURCE)
|
|
140
|
+
|
|
141
|
+
method = method.super_method
|
|
142
|
+
end
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def missing_column_message(label, klass, field, column, types)
|
|
147
|
+
store = stored_attribute_store(klass, resolve_alias(klass, field))
|
|
148
|
+
return store_column_message(label, klass, field, store, column) if store
|
|
149
|
+
|
|
150
|
+
if column == field.to_s
|
|
151
|
+
return "#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
|
|
152
|
+
"#{column_migration_hint(klass, field, types)}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# An alias: the migration must add the column the alias points at,
|
|
156
|
+
# not one named for the alias, which would still leave this check failing.
|
|
157
|
+
"#{label}: '#{field}' is an alias of '#{column}', which does not exist in the database " \
|
|
158
|
+
"(table: #{klass.table_name})." \
|
|
159
|
+
"#{column_migration_hint(klass, column, types.is_a?(Hash) ? types[field.to_sym] : types)}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The store column is gone (dropped, or never migrated), so the accessor
|
|
163
|
+
# reads and writes nothing. That is the column the migration is for, not
|
|
164
|
+
# a column named after the accessor, which would shadow it.
|
|
165
|
+
def store_column_message(label, klass, field, store, column)
|
|
166
|
+
"#{label}: '#{field}' is a store_accessor of :#{store} on #{model_expr(klass)}, " \
|
|
167
|
+
"but the table has no '#{column}' column (table: #{klass.table_name}). " \
|
|
168
|
+
"Add the '#{column}' column that holds it, or declare the field virtual: true if the app handles it itself."
|
|
169
|
+
end
|
|
170
|
+
|
|
64
171
|
# The type half of the drift guard, opt-in via Permittable
|
|
65
172
|
# .check_column_types. It compares GROUPS rather than exact types (see
|
|
66
173
|
# TYPE_GROUPS) and stays silent unless both sides are known, so it can
|
|
@@ -68,18 +175,23 @@ module Permittable
|
|
|
68
175
|
# :datetime after the column became a string, say.
|
|
69
176
|
def ensure_column_type!(label, klass, field, types, allowed = nil, transformed: nil)
|
|
70
177
|
declared = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
71
|
-
|
|
178
|
+
target = resolve_alias(klass, field)
|
|
179
|
+
column = klass.columns_hash[target]
|
|
72
180
|
return unless declared && column
|
|
73
181
|
|
|
74
182
|
ensure_not_array_column!(label, klass, field, declared, column)
|
|
75
183
|
|
|
76
184
|
wanted = TYPE_GROUPS[declared.to_sym]
|
|
77
|
-
enum
|
|
185
|
+
# An enum is keyed by the name it was declared on, which may be the
|
|
186
|
+
# alias (`enum :phase` over `alias_attribute :phase, :status`) or the
|
|
187
|
+
# target it points at.
|
|
188
|
+
enum_name = [field.to_s, target].find { |name| enum_attribute?(klass, name) }
|
|
189
|
+
enum = !enum_name.nil?
|
|
78
190
|
# Checked before the column's type, which an enum's contract does not
|
|
79
191
|
# depend on: see ensure_enum_contract!.
|
|
80
192
|
if enum && wanted == :text
|
|
81
|
-
return ensure_enum_contract!(label, klass,
|
|
82
|
-
transformed: Array(transformed).include?(field.to_sym))
|
|
193
|
+
return ensure_enum_contract!(label, klass, enum_name, declared, allowed && allowed[field.to_sym],
|
|
194
|
+
transformed: Array(transformed).include?(field.to_sym), shown: field.to_s)
|
|
83
195
|
end
|
|
84
196
|
|
|
85
197
|
# `column.type` is nil for a SQL type the adapter does not recognise
|
|
@@ -95,7 +207,8 @@ module Permittable
|
|
|
95
207
|
# On an enum, `virtual: true` would be the wrong advice: it switches
|
|
96
208
|
# off the existence check too, and the field IS backed by this column.
|
|
97
209
|
fix = if enum
|
|
98
|
-
"
|
|
210
|
+
"#{enum_subject(enum_name, field)} is an enum on #{model_expr(klass)}: " \
|
|
211
|
+
"declare it :string, in: #{enum_keys_expr(klass, enum_name)}."
|
|
99
212
|
else
|
|
100
213
|
"Change the contract to match the column, migrate the column to match the contract, " \
|
|
101
214
|
"or declare the field virtual: true if it is not backed by this column."
|
|
@@ -153,7 +266,7 @@ module Permittable
|
|
|
153
266
|
# over a string column) is left compared against the column by choice:
|
|
154
267
|
# an enum's mapping says exactly which strings are valid, an attribute
|
|
155
268
|
# override says nothing a contract could be checked against.
|
|
156
|
-
def ensure_enum_contract!(label, klass, field, declared, listed, transformed: false)
|
|
269
|
+
def ensure_enum_contract!(label, klass, field, declared, listed, transformed: false, shown: field)
|
|
157
270
|
problem =
|
|
158
271
|
if listed.nil?
|
|
159
272
|
"declared :#{declared} without an in:"
|
|
@@ -170,11 +283,19 @@ module Permittable
|
|
|
170
283
|
end
|
|
171
284
|
|
|
172
285
|
raise ArgumentError,
|
|
173
|
-
"#{label}:
|
|
286
|
+
"#{label}: #{enum_subject(field, shown)} is an enum on #{model_expr(klass)}, #{problem} (table: #{klass.table_name}). " \
|
|
174
287
|
"A value outside the enum would pass the contract and then raise on assignment. " \
|
|
175
288
|
"Declare it with in: #{enum_keys_expr(klass, field)}."
|
|
176
289
|
end
|
|
177
290
|
|
|
291
|
+
# The field as an enum error names it: `'phase'`, or `'state' (an alias of
|
|
292
|
+
# 'status')` when the enum is declared on the alias's target.
|
|
293
|
+
def enum_subject(enum_name, shown)
|
|
294
|
+
return "'#{shown}'" if enum_name.to_s == shown.to_s
|
|
295
|
+
|
|
296
|
+
"'#{shown}' (an alias of '#{enum_name}')"
|
|
297
|
+
end
|
|
298
|
+
|
|
178
299
|
def enum_values(klass, field)
|
|
179
300
|
mapping = klass.defined_enums[field.to_s]
|
|
180
301
|
mapping.keys.map(&:to_s) + mapping.values.grep(String)
|
data/lib/permittable/contract.rb
CHANGED
|
@@ -140,7 +140,10 @@ module Permittable
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
# The request-body schema for this contract — JSON Schema draft 2020-12,
|
|
143
|
-
#
|
|
143
|
+
# what the OpenAPI exporter emits for a controller rule, less what only an
|
|
144
|
+
# HTTP request carries (the form keys a rootless `unknown: :error` body
|
|
145
|
+
# lists, the route's path parameters, a multipart body's flat part names):
|
|
146
|
+
# a contract exempts none of those.
|
|
144
147
|
def json_schema
|
|
145
148
|
JsonSchema.rule(rule)
|
|
146
149
|
end
|
|
@@ -61,12 +61,29 @@ module Permittable
|
|
|
61
61
|
# with html listed first). Without it this JSON body goes out as HTML.
|
|
62
62
|
# A JSON type the response already carries (a registered
|
|
63
63
|
# application/vnd.api+json, say) is the host's choice and is kept.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
controller.render(**options)
|
|
64
|
+
type = json_media_type?(response_media_type(controller)) ? nil : "application/json"
|
|
65
|
+
render_json(controller, { success: false, error: error }, status: status, content_type: type)
|
|
67
66
|
end
|
|
68
67
|
end
|
|
69
68
|
|
|
69
|
+
# `render json:`, or — for a bare ActionController::Metal that never
|
|
70
|
+
# included ActionController::Rendering, and so has no `render` — the same
|
|
71
|
+
# response written through Metal's own status=, content_type= and
|
|
72
|
+
# response_body=. `render json:` calls `to_json` on a Hash, so the bytes
|
|
73
|
+
# are the same. A controller with `render` always takes the first path.
|
|
74
|
+
def render_json(controller, payload, status:, content_type: nil)
|
|
75
|
+
if !controller.respond_to?(:render) && controller.respond_to?(:response_body=)
|
|
76
|
+
controller.status = status
|
|
77
|
+
controller.content_type = content_type if content_type
|
|
78
|
+
controller.response_body = [payload.to_json]
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
options = { json: payload, status: status }
|
|
83
|
+
options[:content_type] = content_type if content_type
|
|
84
|
+
controller.render(**options)
|
|
85
|
+
end
|
|
86
|
+
|
|
70
87
|
# The media type (no parameters) the response already carries, or nil when
|
|
71
88
|
# it has none yet or the host has no response to ask.
|
|
72
89
|
def response_media_type(controller)
|
|
@@ -160,7 +177,7 @@ module Permittable
|
|
|
160
177
|
instance = request_path(controller)
|
|
161
178
|
problem[:instance] = instance if instance
|
|
162
179
|
problem[:errors] = details if details && !details.empty?
|
|
163
|
-
controller
|
|
180
|
+
render_json(controller, problem, status: status, content_type: PROBLEM_MEDIA_TYPE)
|
|
164
181
|
end
|
|
165
182
|
|
|
166
183
|
# RFC 9457: an absent `type` means "about:blank", so that is the honest
|
|
@@ -67,7 +67,12 @@ module Permittable
|
|
|
67
67
|
decimal: { "type" => %w[string number], "format" => "decimal" },
|
|
68
68
|
boolean: { "type" => "boolean" },
|
|
69
69
|
date: { "type" => "string", "format" => "date" },
|
|
70
|
-
datetime: { "type" => "string", "format" => "date-time" }
|
|
70
|
+
datetime: { "type" => "string", "format" => "date-time" },
|
|
71
|
+
# OpenAPI 3.1 / JSON Schema 2020-12 describe a file as a string with a
|
|
72
|
+
# contentMediaType (`format: binary` is the 3.0 spelling, which 3.1
|
|
73
|
+
# dropped). It rides in a multipart/form-data body, see
|
|
74
|
+
# OpenApi.body_media_type.
|
|
75
|
+
file: { "type" => "string", "contentMediaType" => "application/octet-stream" }
|
|
71
76
|
}.freeze
|
|
72
77
|
|
|
73
78
|
# Request-body schema for one rule from `permittable_contracts` /
|
|
@@ -218,7 +223,7 @@ module Permittable
|
|
|
218
223
|
# applies here too — a :decimal in: member is otherwise always
|
|
219
224
|
# exported as a string, so a numerically-exported default: is no
|
|
220
225
|
# longer a member of its own enum's exported list. in_published (a
|
|
221
|
-
# :
|
|
226
|
+
# :datetime member kept in its authored String form, see
|
|
222
227
|
# Coercion.published_in_member) takes precedence over the cast
|
|
223
228
|
# members when both apply.
|
|
224
229
|
schema["enum"] = (field[:in_published] || allowed).map { |v| json_value(v, decimal: :number) }
|
|
@@ -359,7 +364,8 @@ module Permittable
|
|
|
359
364
|
# takes. A nullable :string keeps the floor it has always had (stricter
|
|
360
365
|
# than the server, the safe direction); the other types start without.
|
|
361
366
|
def blank_floor?(field)
|
|
362
|
-
|
|
367
|
+
# A :file is no string at the wire: a 0-byte upload is accepted.
|
|
368
|
+
field[:required] && field[:type] != :file && (field[:type] == :string || !field[:nullable])
|
|
363
369
|
end
|
|
364
370
|
|
|
365
371
|
def apply_pattern!(schema, regexp)
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -201,12 +201,173 @@ module Permittable
|
|
|
201
201
|
end
|
|
202
202
|
|
|
203
203
|
def rule_request_body(rule)
|
|
204
|
+
media_type = body_media_type(rule)
|
|
205
|
+
schema = JsonSchema.rule(rule)
|
|
206
|
+
schema = form_schema(schema, rule) if media_type == "multipart/form-data"
|
|
207
|
+
allow_form_keys!(schema) if media_type != "multipart/form-data" && rule[:unknown] == :error && !rule[:root]
|
|
204
208
|
{
|
|
205
209
|
"required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
|
|
206
|
-
"content" => {
|
|
210
|
+
"content" => { media_type => { "schema" => schema } }
|
|
207
211
|
}
|
|
208
212
|
end
|
|
209
213
|
|
|
214
|
+
# Under `unknown: :error` the runtime still exempts Rails' form keys
|
|
215
|
+
# (FORM_KEYS) at the top of a rootless contract, so a JSON body carrying
|
|
216
|
+
# `authenticity_token` or `_method` (a form's fields sent as JSON, a record
|
|
217
|
+
# echoed back) is accepted, and the body schema must not forbid it. They
|
|
218
|
+
# stay in as free, never-required properties, as the multipart body lists
|
|
219
|
+
# them (see form_schema). A name the contract declares keeps its own
|
|
220
|
+
# schema. A `root:` rule needs none of this: the keys sit outside the root,
|
|
221
|
+
# in a wrapper that is permissive already. The plain JsonSchema.rule and
|
|
222
|
+
# Contract#json_schema describe the contract, not an HTTP body, and a
|
|
223
|
+
# standalone Contract exempts no form key, so they do not list them.
|
|
224
|
+
def allow_form_keys!(schema)
|
|
225
|
+
Permittable::FORM_KEYS.each { |key| schema["properties"][key] ||= {} }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# A multipart body is not one JSON object: each part is a form field, and
|
|
229
|
+
# Rails reads the part NAMES. `user[avatar]` is the avatar of the `user`
|
|
230
|
+
# hash and a repeated `docs[]` is an array, so the schema's properties
|
|
231
|
+
# carry those names, flat, instead of nesting `user` as a single part
|
|
232
|
+
# (which a client would send as one JSON part, unable to hold the file).
|
|
233
|
+
# An array of hashes is sent in the indexed form Rails' fields_for renders
|
|
234
|
+
# (`items[0][photo]`, `items[1][photo]`, which the runtime reads), so its
|
|
235
|
+
# leaves are `patternProperties` with an anchored pattern each. What a
|
|
236
|
+
# flat body cannot say, a container's own `required`, bounds, `nullable`,
|
|
237
|
+
# `desc:` and `validate:` marker, rides in `x-permittable-form-groups`,
|
|
238
|
+
# keyed by the container's bracket name.
|
|
239
|
+
#
|
|
240
|
+
# Under `unknown: :error` the flat body refuses every other name, which
|
|
241
|
+
# covers each nesting level at once (`user[address][bogus]` is no listed
|
|
242
|
+
# name). It must then also list what the server still accepts: a
|
|
243
|
+
# nullable container's null (`user[docs]=""`), the keys of a :json
|
|
244
|
+
# field (`user[extra][any]`), and, for a `root:` rule, any part outside
|
|
245
|
+
# the root, which the runtime never reads; a rootless rule instead gets
|
|
246
|
+
# the form keys (FORM_KEYS) the runtime exempts at the top level.
|
|
247
|
+
def form_schema(json_schema, rule)
|
|
248
|
+
root = rule[:root] && rule[:root].to_s
|
|
249
|
+
form = { properties: {}, patterns: {}, required: [], groups: {}, strict: rule[:unknown] == :error }
|
|
250
|
+
flatten_form(json_schema, [], true, form, skip_group: root)
|
|
251
|
+
if form[:strict] && root
|
|
252
|
+
form[:patterns]["^(?!#{form_escape(root)}(?:\\[|$))"] = {}
|
|
253
|
+
elsif form[:strict]
|
|
254
|
+
form[:patterns]["^(?:#{Permittable::FORM_KEYS.map { |key| form_escape(key) }.join('|')})$"] = {}
|
|
255
|
+
end
|
|
256
|
+
schema = { "type" => "object", "properties" => form[:properties] }
|
|
257
|
+
schema["patternProperties"] = form[:patterns] unless form[:patterns].empty?
|
|
258
|
+
schema["additionalProperties"] = false if form[:strict]
|
|
259
|
+
schema["required"] = form[:required] unless form[:required].empty?
|
|
260
|
+
schema["x-permittable-form-groups"] = form[:groups] unless form[:groups].empty?
|
|
261
|
+
schema["x-permittable-finalize"] = true if rule[:finalize]
|
|
262
|
+
schema
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
GROUP_KEYS = %w[minItems maxItems description x-permittable-custom-validation].freeze
|
|
266
|
+
|
|
267
|
+
# `path` is the part name as segments: a key, or :index for the number
|
|
268
|
+
# an array of hashes' element carries. A path with no :index is a plain
|
|
269
|
+
# property name; one with an :index becomes a pattern. `required` is
|
|
270
|
+
# relative to the element inside an array of hashes: patterns cannot
|
|
271
|
+
# carry it, but a group inside the element (`items[][info]`) can.
|
|
272
|
+
def flatten_form(schema, path, required, form, skip_group: nil)
|
|
273
|
+
if form_container?(schema, "object")
|
|
274
|
+
form_group!(schema, path, required, form, skip_group)
|
|
275
|
+
schema["properties"].each do |key, child|
|
|
276
|
+
flatten_form(child, path + [key], required && schema.fetch("required", []).include?(key), form,
|
|
277
|
+
skip_group: skip_group)
|
|
278
|
+
end
|
|
279
|
+
elsif form_container?(schema, "array") && form_container?(schema["items"], "object")
|
|
280
|
+
form_group!(schema, path, required, form, skip_group)
|
|
281
|
+
flatten_form(schema["items"], path + [:index], true, form, skip_group: skip_group)
|
|
282
|
+
else
|
|
283
|
+
if Array(schema["type"]).include?("array")
|
|
284
|
+
schema, required = form_list(schema, path, required, form)
|
|
285
|
+
path += [""]
|
|
286
|
+
end
|
|
287
|
+
form[:patterns]["#{form_pattern(path).delete_suffix('$')}\\["] = {} if form[:strict] && form_opaque?(schema)
|
|
288
|
+
form_leaf!(schema, path, required, form)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def form_opaque?(schema)
|
|
293
|
+
Array(schema["type"]).include?("object") && !schema.key?("properties")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# The part a nullable container's null is sent as: an empty string.
|
|
297
|
+
def form_null!(path, form)
|
|
298
|
+
form_leaf!({ "const" => "" }, path, false, form) if form[:strict]
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# A nullable array's null is an empty part named without the `[]`
|
|
302
|
+
# (`docs=""`); `docs[]=""` is an empty array. So the `docs[]` leaf loses
|
|
303
|
+
# "null" and the group entry, under the name the null is sent as, says so,
|
|
304
|
+
# and carries `required` too: listing `docs[]` as required would refuse
|
|
305
|
+
# the null, which sends no `docs[]` part. Returns [leaf schema, required].
|
|
306
|
+
def form_list(schema, path, required, form)
|
|
307
|
+
types = Array(schema["type"])
|
|
308
|
+
return [schema, required] unless types.include?("null")
|
|
309
|
+
|
|
310
|
+
form[:groups][form_name(path.map { |k| k == :index ? "" : k })] = { "required" => required, "nullable" => true }
|
|
311
|
+
form_null!(path, form)
|
|
312
|
+
types -= ["null"]
|
|
313
|
+
[schema.merge("type" => types.one? ? types.first : types), false]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def form_leaf!(schema, path, required, form)
|
|
317
|
+
if path.include?(:index)
|
|
318
|
+
form[:patterns][form_pattern(path)] = schema
|
|
319
|
+
else
|
|
320
|
+
name = form_name(path)
|
|
321
|
+
form[:properties][name] = schema
|
|
322
|
+
form[:required] << name if required
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def form_name(path)
|
|
327
|
+
path.each_with_index.map { |key, i| i.zero? ? key.to_s : "[#{key}]" }.join
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def form_pattern(path)
|
|
331
|
+
body = path.each_with_index.map do |key, i|
|
|
332
|
+
text = key == :index ? '\\d+' : form_escape(key)
|
|
333
|
+
i.zero? ? text : "\\[#{text}\\]"
|
|
334
|
+
end
|
|
335
|
+
"^#{body.join}$"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def form_escape(key)
|
|
339
|
+
key.to_s.gsub(JsonSchema::EcmaPattern::SYNTAX_ESCAPE) { |c| "\\#{c}" }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def form_group!(schema, path, required, form, skip_group)
|
|
343
|
+
return if path.empty? || path == [skip_group] || path.last == :index
|
|
344
|
+
|
|
345
|
+
group = { "required" => required }
|
|
346
|
+
if Array(schema["type"]).include?("null")
|
|
347
|
+
group["nullable"] = true
|
|
348
|
+
form_null!(path, form)
|
|
349
|
+
end
|
|
350
|
+
GROUP_KEYS.each { |key| group[key] = schema[key] if schema.key?(key) }
|
|
351
|
+
form[:groups][form_name(path.map { |k| k == :index ? "" : k })] = group
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def form_container?(schema, kind)
|
|
355
|
+
return false unless schema.is_a?(Hash) && Array(schema["type"]).include?(kind)
|
|
356
|
+
|
|
357
|
+
kind == "array" ? schema.key?("items") : schema["properties"].is_a?(Hash)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# An upload cannot travel in JSON, so a rule with a :file field anywhere
|
|
361
|
+
# in it describes a multipart/form-data body; every other rule stays
|
|
362
|
+
# application/json.
|
|
363
|
+
def body_media_type(rule)
|
|
364
|
+
rule[:fields].any? { |f| file_field?(f) } ? "multipart/form-data" : "application/json"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def file_field?(field)
|
|
368
|
+
field[:type] == :file || field[:of] == :file || (field[:fields] || []).any? { |f| file_field?(f) }
|
|
369
|
+
end
|
|
370
|
+
|
|
210
371
|
# { action => operation } for every action the controller's contracts
|
|
211
372
|
# cover, resolved through permit_rule_for so last-matching-rule-wins holds
|
|
212
373
|
# in the documentation exactly as it does at request time.
|
|
@@ -319,7 +480,12 @@ module Permittable
|
|
|
319
480
|
routes.select { |route| OPENAPI_VERBS.include?(verb_of(route)) }
|
|
320
481
|
.group_by { |route| [route[:controller].to_s, route[:action].to_s] }
|
|
321
482
|
.transform_values do |matching|
|
|
322
|
-
matching.
|
|
483
|
+
matching.group_by { |route| [route[:path], verb_of(route), route[:template]] }.map do |slot, same|
|
|
484
|
+
# Several routes can reach one slot (a constrained one ahead
|
|
485
|
+
# of a plain one): a name leaves the body only if every one
|
|
486
|
+
# of them fills it.
|
|
487
|
+
[*slot, same.map { |route| Array(route[:defaults]) }.reduce(:&)]
|
|
488
|
+
end
|
|
323
489
|
end
|
|
324
490
|
end
|
|
325
491
|
|
|
@@ -420,9 +586,9 @@ module Permittable
|
|
|
420
586
|
slots << OperationSlot.new(holder, action, nil, operation["operationId"], owner) unless holder.key?(action)
|
|
421
587
|
holder[action] = operation
|
|
422
588
|
else
|
|
423
|
-
free.each do |path, verb, template|
|
|
589
|
+
free.each do |path, verb, template, defaults|
|
|
424
590
|
holder = (paths[path] ||= {})
|
|
425
|
-
placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path)
|
|
591
|
+
placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path, defaults)
|
|
426
592
|
holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
|
|
427
593
|
slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
|
|
428
594
|
end
|
|
@@ -544,14 +710,22 @@ module Permittable
|
|
|
544
710
|
# `own` is the route's own spelling of the template, which differs
|
|
545
711
|
# from `path` when collapse_shapes placed the route under another
|
|
546
712
|
# route's spelling: the same shape, so the names pair up by position.
|
|
547
|
-
|
|
713
|
+
#
|
|
714
|
+
# `defaults` names what the route fills from its `defaults:` (see
|
|
715
|
+
# route_defaults). The router merges those over the body like a
|
|
716
|
+
# templated name, so they leave the body the same way, but the URL does
|
|
717
|
+
# not carry them and they are no path parameter. A name the path
|
|
718
|
+
# templates is that parameter instead: `get "o(/:id)", defaults:
|
|
719
|
+
# { id: 5 }` is a parameter on `/o/{id}` and a default only on `/o`.
|
|
720
|
+
def with_path_parameters(operation, path, rule, own = path, defaults = [])
|
|
548
721
|
# A name the path templates twice (a mount and an engine route both
|
|
549
722
|
# name `:id`) is declared once: OpenAPI forbids two parameters with
|
|
550
723
|
# one name and location.
|
|
551
724
|
variables = path.scan(/\{(\w+)\}/).flatten.uniq
|
|
552
|
-
return operation if variables.empty?
|
|
553
|
-
|
|
554
725
|
own_names = own.scan(/\{(\w+)\}/).flatten
|
|
726
|
+
supplied = Array(defaults) - own_names
|
|
727
|
+
return operation if variables.empty? && supplied.empty?
|
|
728
|
+
|
|
555
729
|
fields = path_fields(rule, path.scan(/\{(\w+)\}/).flatten.zip(own_names).uniq(&:first))
|
|
556
730
|
parameters = variables.map do |name|
|
|
557
731
|
schema = fields[name] ? path_schema(fields[name]) : { "type" => "string" }
|
|
@@ -561,8 +735,8 @@ module Permittable
|
|
|
561
735
|
# it; emission stays deterministic either way.
|
|
562
736
|
operation.each_with_object({}) do |(key, value), out|
|
|
563
737
|
if key == "requestBody"
|
|
564
|
-
out["parameters"] = parameters
|
|
565
|
-
value = body_without(rule, fields.values, own_names)
|
|
738
|
+
out["parameters"] = parameters unless parameters.empty?
|
|
739
|
+
value = body_without(rule, fields.values + default_fields(rule, supplied), own_names + supplied)
|
|
566
740
|
end
|
|
567
741
|
out[key] = value unless value.nil?
|
|
568
742
|
end
|
|
@@ -590,6 +764,16 @@ module Permittable
|
|
|
590
764
|
end.to_h
|
|
591
765
|
end
|
|
592
766
|
|
|
767
|
+
# The rootless contract's fields for the names the route's `defaults:`
|
|
768
|
+
# fill. Unlike a path parameter's, any kind of field goes: the router
|
|
769
|
+
# overwrites the body's value whatever its shape, and no parameter has to
|
|
770
|
+
# carry it.
|
|
771
|
+
def default_fields(rule, names)
|
|
772
|
+
return [] if rule[:root]
|
|
773
|
+
|
|
774
|
+
rule[:fields].select { |f| names.include?(f[:name].to_s) }
|
|
775
|
+
end
|
|
776
|
+
|
|
593
777
|
# A moved field's schema as the path parameter documents it. The URL
|
|
594
778
|
# always supplies the segment, so a `default:` never applies and the
|
|
595
779
|
# value is never null: `nullable:` would only add "null" to the type.
|
|
@@ -610,7 +794,8 @@ module Permittable
|
|
|
610
794
|
# produces, so the schema must not forbid it. Each name the route
|
|
611
795
|
# templates therefore stays in the body as an unconstrained property,
|
|
612
796
|
# any value and never required, since the value is read from the URL and
|
|
613
|
-
# nothing in the body can make the request wrong. A
|
|
797
|
+
# nothing in the body can make the request wrong. A name the route's
|
|
798
|
+
# `defaults:` fill is treated the same. A rooted contract
|
|
614
799
|
# needs none of this: its wrapper object is permissive already.
|
|
615
800
|
def body_without(rule, moved, templated)
|
|
616
801
|
rest = rule[:fields] - moved
|
|
@@ -619,7 +804,7 @@ module Permittable
|
|
|
619
804
|
body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
|
|
620
805
|
return body unless rule[:unknown] == :error && !rule[:root]
|
|
621
806
|
|
|
622
|
-
properties = body["content"]["
|
|
807
|
+
properties = body["content"].values.first["schema"]["properties"]
|
|
623
808
|
templated.each { |name| properties[name] ||= {} }
|
|
624
809
|
body
|
|
625
810
|
end
|
|
@@ -806,6 +991,9 @@ module Permittable
|
|
|
806
991
|
if engines && (mounted = mounted_routes(route)) && visiting.none? { |above| above.equal?(mounted) }
|
|
807
992
|
found = collect_routes(mounted, engines: engines, prefixes: mount_prefixes(prefixes, route), parents: Array(id),
|
|
808
993
|
visiting: visiting, reachable: reachable && unconditional?(route))
|
|
994
|
+
# What the mount fills reaches the engine's requests too.
|
|
995
|
+
mount_defaults = route_defaults(route)
|
|
996
|
+
found = found.map { |d| d.merge(defaults: (mount_defaults + Array(d[:defaults])).uniq) } unless mount_defaults.empty?
|
|
809
997
|
verbs = route.verb.to_s.downcase.split("|")
|
|
810
998
|
next verbs.empty? ? found : found.select { |descriptor| verbs.include?(descriptor[:verb]) }
|
|
811
999
|
end
|
|
@@ -818,6 +1006,7 @@ module Permittable
|
|
|
818
1006
|
verb = ALL_VERBS if verb.empty?
|
|
819
1007
|
|
|
820
1008
|
variants = optional_variants(route.path.spec, requirements, prefixes)
|
|
1009
|
+
defaults = route_defaults(route)
|
|
821
1010
|
# Read once per route; drop_shadowed reads it, then strips it.
|
|
822
1011
|
unconditional = reachable && unconditional?(route)
|
|
823
1012
|
# One route can answer several verbs (`match via: [:patch, :put]`, and
|
|
@@ -829,12 +1018,25 @@ module Permittable
|
|
|
829
1018
|
variants.map do |path, shadows|
|
|
830
1019
|
descriptor = { controller: requirements[:controller], action: requirements[:action],
|
|
831
1020
|
verb: single.downcase, path: path, route: id, unconditional: unconditional }
|
|
1021
|
+
descriptor = descriptor.merge(defaults: defaults) unless defaults.empty?
|
|
832
1022
|
shadows.empty? ? descriptor : descriptor.merge(shadows: shadows)
|
|
833
1023
|
end
|
|
834
1024
|
end
|
|
835
1025
|
end
|
|
836
1026
|
end
|
|
837
1027
|
|
|
1028
|
+
# The names a route fills from its `defaults:` (`scope`, `resources` and
|
|
1029
|
+
# `mount` take them too), as strings. The router puts them into
|
|
1030
|
+
# path_parameters beside the URL's own, so the client cannot set them;
|
|
1031
|
+
# `controller`, `action` and `format` are the router's own keys and
|
|
1032
|
+
# are left out, as the runtime leaves them out of `unknown:`. A
|
|
1033
|
+
# hand-built route that cannot say fills nothing.
|
|
1034
|
+
def route_defaults(route)
|
|
1035
|
+
return [] unless route.respond_to?(:defaults)
|
|
1036
|
+
|
|
1037
|
+
route.defaults.keys.map(&:to_s) - %w[controller action format]
|
|
1038
|
+
end
|
|
1039
|
+
|
|
838
1040
|
# The Prefix of each variant of a mount's path under each of `prefixes`,
|
|
839
1041
|
# outermost mount first, and within a mount present before absent, which
|
|
840
1042
|
# is the order Rails' regexp tries them in. A variant is shadowed by every
|