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.
@@ -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
- unless klass.column_names.include?(field.to_s)
52
- raise ArgumentError, ignored_column_message(label, klass, field) if ignored_column?(klass, field)
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
- column = klass.columns_hash[field.to_s]
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 = enum_attribute?(klass, field)
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, field, declared, allowed && allowed[field.to_sym],
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
- "'#{field}' is an enum on #{model_expr(klass)}: declare it :string, in: #{enum_keys_expr(klass, field)}."
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}: '#{field}' is an enum on #{model_expr(klass)}, #{problem} (table: #{klass.table_name}). " \
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)
@@ -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
- # identical to what the OpenAPI exporter emits for a controller rule.
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
- options = { json: { success: false, error: error }, status: status }
65
- options[:content_type] = "application/json" unless json_media_type?(response_media_type(controller))
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.render(json: problem, status: status, content_type: PROBLEM_MEDIA_TYPE)
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
- # :date/:datetime member kept in its authored String form, see
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
- field[:required] && (field[:type] == :string || !field[:nullable])
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)
@@ -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" => { "application/json" => { "schema" => JsonSchema.rule(rule) } }
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.map { |route| [route[:path], verb_of(route), route[:template]] }.uniq
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
- def with_path_parameters(operation, path, rule, own = path)
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 rooted contract
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"]["application/json"]["schema"]["properties"]
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