permittable 0.13.0 → 0.14.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.
@@ -91,6 +91,54 @@ module Permittable
91
91
  @concern_methods ||= Permittable.public_instance_methods(false).map(&:to_s).freeze
92
92
  end
93
93
 
94
+ # Gems whose public methods are the framework's own: the ones a controller
95
+ # gets from the modules Rails mixes in (Rendering, Head, Callbacks, ViewPaths,
96
+ # Rescuable, ...) and from the class attributes those define on it.
97
+ FRAMEWORK_GEMS = %w[actionpack actionview activesupport activemodel railties].freeze
98
+
99
+ # Rails reads a controller's internal methods off its nearest abstract
100
+ # class: ActionController::Base includes all of those modules, so they are
101
+ # internal on a Base or API controller, but ActionController::Metal includes
102
+ # none, so on one built directly on it every public method of an included
103
+ # module is an action_methods entry. A method is the framework's when it
104
+ # is defined in one of its gems, by whichever module or class_attribute
105
+ # put it on the controller; a method the app defines, in the class or in a
106
+ # concern of its own, stays an action. So does one a superclass declares in
107
+ # its own file, even a framework controller's (Rails::HealthController#show):
108
+ # a method defined elsewhere in a gem, such as the class_attribute readers
109
+ # an app's parent class gets from the modules it includes, is not one.
110
+ def framework_method?(controller, action)
111
+ return false unless controller.respond_to?(:instance_method)
112
+
113
+ method = controller.instance_method(action)
114
+ file = method.source_location&.first
115
+ return false if file.nil? || framework_roots.none? { |root| file.start_with?(root) }
116
+
117
+ !declared_by_superclass?(method, controller, file)
118
+ rescue NameError
119
+ false
120
+ end
121
+
122
+ # Whether `method` is written in the file that defines its owner, a class
123
+ # other than the controller itself. An anonymous owner has no such file.
124
+ def declared_by_superclass?(method, controller, file)
125
+ owner = method.owner
126
+ owner.is_a?(Class) && owner != controller && !owner.name.nil? &&
127
+ Object.const_source_location(owner.name)&.first == file
128
+ end
129
+
130
+ # Resolved lazily, like concern_methods: the gems load after this file.
131
+ # Real paths, which is what source_location reports: a gem's own path is
132
+ # only expanded, so one reached through a symlink would never match.
133
+ def framework_roots
134
+ @framework_roots ||= FRAMEWORK_GEMS.filter_map do |name|
135
+ path = Gem.loaded_specs[name]&.full_gem_path or next
136
+ File.join(File.realpath(path), "")
137
+ rescue SystemCallError
138
+ File.join(path, "")
139
+ end.freeze
140
+ end
141
+
94
142
  # Shared `components` for any document referencing Permittable responses.
95
143
  #
96
144
  # Unlike a rule's monitor mode — which the exporter reads only from the
@@ -153,12 +201,158 @@ module Permittable
153
201
  end
154
202
 
155
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"
156
207
  {
157
208
  "required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
158
- "content" => { "application/json" => { "schema" => JsonSchema.rule(rule) } }
209
+ "content" => { media_type => { "schema" => schema } }
159
210
  }
160
211
  end
161
212
 
213
+ # A multipart body is not one JSON object: each part is a form field, and
214
+ # Rails reads the part NAMES. `user[avatar]` is the avatar of the `user`
215
+ # hash and a repeated `docs[]` is an array, so the schema's properties
216
+ # carry those names, flat, instead of nesting `user` as a single part
217
+ # (which a client would send as one JSON part, unable to hold the file).
218
+ # An array of hashes is sent in the indexed form Rails' fields_for renders
219
+ # (`items[0][photo]`, `items[1][photo]`, which the runtime reads), so its
220
+ # leaves are `patternProperties` with an anchored pattern each. What a
221
+ # flat body cannot say, a container's own `required`, bounds, `nullable`,
222
+ # `desc:` and `validate:` marker, rides in `x-permittable-form-groups`,
223
+ # keyed by the container's bracket name.
224
+ #
225
+ # Under `unknown: :error` the flat body refuses every other name, which
226
+ # covers each nesting level at once (`user[address][bogus]` is no listed
227
+ # name). It must then also list what the server still accepts: a
228
+ # nullable container's null (`user[docs]=""`), the keys of a :json
229
+ # field (`user[extra][any]`), and, for a `root:` rule, any part outside
230
+ # the root, which the runtime never reads; a rootless rule instead gets
231
+ # the form keys (FORM_KEYS) the runtime exempts at the top level.
232
+ def form_schema(json_schema, rule)
233
+ root = rule[:root] && rule[:root].to_s
234
+ form = { properties: {}, patterns: {}, required: [], groups: {}, strict: rule[:unknown] == :error }
235
+ flatten_form(json_schema, [], true, form, skip_group: root)
236
+ if form[:strict] && root
237
+ form[:patterns]["^(?!#{form_escape(root)}(?:\\[|$))"] = {}
238
+ elsif form[:strict]
239
+ form[:patterns]["^(?:#{Permittable::FORM_KEYS.map { |key| form_escape(key) }.join('|')})$"] = {}
240
+ end
241
+ schema = { "type" => "object", "properties" => form[:properties] }
242
+ schema["patternProperties"] = form[:patterns] unless form[:patterns].empty?
243
+ schema["additionalProperties"] = false if form[:strict]
244
+ schema["required"] = form[:required] unless form[:required].empty?
245
+ schema["x-permittable-form-groups"] = form[:groups] unless form[:groups].empty?
246
+ schema["x-permittable-finalize"] = true if rule[:finalize]
247
+ schema
248
+ end
249
+
250
+ GROUP_KEYS = %w[minItems maxItems description x-permittable-custom-validation].freeze
251
+
252
+ # `path` is the part name as segments: a key, or :index for the number
253
+ # an array of hashes' element carries. A path with no :index is a plain
254
+ # property name; one with an :index becomes a pattern. `required` is
255
+ # relative to the element inside an array of hashes: patterns cannot
256
+ # carry it, but a group inside the element (`items[][info]`) can.
257
+ def flatten_form(schema, path, required, form, skip_group: nil)
258
+ if form_container?(schema, "object")
259
+ form_group!(schema, path, required, form, skip_group)
260
+ schema["properties"].each do |key, child|
261
+ flatten_form(child, path + [key], required && schema.fetch("required", []).include?(key), form,
262
+ skip_group: skip_group)
263
+ end
264
+ elsif form_container?(schema, "array") && form_container?(schema["items"], "object")
265
+ form_group!(schema, path, required, form, skip_group)
266
+ flatten_form(schema["items"], path + [:index], true, form, skip_group: skip_group)
267
+ else
268
+ if Array(schema["type"]).include?("array")
269
+ schema, required = form_list(schema, path, required, form)
270
+ path += [""]
271
+ end
272
+ form[:patterns]["#{form_pattern(path).delete_suffix('$')}\\["] = {} if form[:strict] && form_opaque?(schema)
273
+ form_leaf!(schema, path, required, form)
274
+ end
275
+ end
276
+
277
+ def form_opaque?(schema)
278
+ Array(schema["type"]).include?("object") && !schema.key?("properties")
279
+ end
280
+
281
+ # The part a nullable container's null is sent as: an empty string.
282
+ def form_null!(path, form)
283
+ form_leaf!({ "const" => "" }, path, false, form) if form[:strict]
284
+ end
285
+
286
+ # A nullable array's null is an empty part named without the `[]`
287
+ # (`docs=""`); `docs[]=""` is an empty array. So the `docs[]` leaf loses
288
+ # "null" and the group entry, under the name the null is sent as, says so,
289
+ # and carries `required` too: listing `docs[]` as required would refuse
290
+ # the null, which sends no `docs[]` part. Returns [leaf schema, required].
291
+ def form_list(schema, path, required, form)
292
+ types = Array(schema["type"])
293
+ return [schema, required] unless types.include?("null")
294
+
295
+ form[:groups][form_name(path.map { |k| k == :index ? "" : k })] = { "required" => required, "nullable" => true }
296
+ form_null!(path, form)
297
+ types -= ["null"]
298
+ [schema.merge("type" => types.one? ? types.first : types), false]
299
+ end
300
+
301
+ def form_leaf!(schema, path, required, form)
302
+ if path.include?(:index)
303
+ form[:patterns][form_pattern(path)] = schema
304
+ else
305
+ name = form_name(path)
306
+ form[:properties][name] = schema
307
+ form[:required] << name if required
308
+ end
309
+ end
310
+
311
+ def form_name(path)
312
+ path.each_with_index.map { |key, i| i.zero? ? key.to_s : "[#{key}]" }.join
313
+ end
314
+
315
+ def form_pattern(path)
316
+ body = path.each_with_index.map do |key, i|
317
+ text = key == :index ? '\\d+' : form_escape(key)
318
+ i.zero? ? text : "\\[#{text}\\]"
319
+ end
320
+ "^#{body.join}$"
321
+ end
322
+
323
+ def form_escape(key)
324
+ key.to_s.gsub(JsonSchema::EcmaPattern::SYNTAX_ESCAPE) { |c| "\\#{c}" }
325
+ end
326
+
327
+ def form_group!(schema, path, required, form, skip_group)
328
+ return if path.empty? || path == [skip_group] || path.last == :index
329
+
330
+ group = { "required" => required }
331
+ if Array(schema["type"]).include?("null")
332
+ group["nullable"] = true
333
+ form_null!(path, form)
334
+ end
335
+ GROUP_KEYS.each { |key| group[key] = schema[key] if schema.key?(key) }
336
+ form[:groups][form_name(path.map { |k| k == :index ? "" : k })] = group
337
+ end
338
+
339
+ def form_container?(schema, kind)
340
+ return false unless schema.is_a?(Hash) && Array(schema["type"]).include?(kind)
341
+
342
+ kind == "array" ? schema.key?("items") : schema["properties"].is_a?(Hash)
343
+ end
344
+
345
+ # An upload cannot travel in JSON, so a rule with a :file field anywhere
346
+ # in it describes a multipart/form-data body; every other rule stays
347
+ # application/json.
348
+ def body_media_type(rule)
349
+ rule[:fields].any? { |f| file_field?(f) } ? "multipart/form-data" : "application/json"
350
+ end
351
+
352
+ def file_field?(field)
353
+ field[:type] == :file || field[:of] == :file || (field[:fields] || []).any? { |f| file_field?(f) }
354
+ end
355
+
162
356
  # { action => operation } for every action the controller's contracts
163
357
  # cover, resolved through permit_rule_for so last-matching-rule-wins holds
164
358
  # in the documentation exactly as it does at request time.
@@ -176,7 +370,8 @@ module Permittable
176
370
  return explicit unless contracts.any? { |rule| rule[:actions].empty? }
177
371
  return explicit + ["*"] unless controller.respond_to?(:action_methods)
178
372
 
179
- explicit + (controller.action_methods.map(&:to_s).sort - explicit - concern_methods)
373
+ actions = controller.action_methods.map(&:to_s).sort - explicit - concern_methods
374
+ explicit + actions.reject { |action| framework_method?(controller, action) }
180
375
  end
181
376
 
182
377
  def operation_for(controller, action)
@@ -270,7 +465,12 @@ module Permittable
270
465
  routes.select { |route| OPENAPI_VERBS.include?(verb_of(route)) }
271
466
  .group_by { |route| [route[:controller].to_s, route[:action].to_s] }
272
467
  .transform_values do |matching|
273
- matching.map { |route| [route[:path], verb_of(route), route[:template]] }.uniq
468
+ matching.group_by { |route| [route[:path], verb_of(route), route[:template]] }.map do |slot, same|
469
+ # Several routes can reach one slot (a constrained one ahead
470
+ # of a plain one): a name leaves the body only if every one
471
+ # of them fills it.
472
+ [*slot, same.map { |route| Array(route[:defaults]) }.reduce(:&)]
473
+ end
274
474
  end
275
475
  end
276
476
 
@@ -371,9 +571,9 @@ module Permittable
371
571
  slots << OperationSlot.new(holder, action, nil, operation["operationId"], owner) unless holder.key?(action)
372
572
  holder[action] = operation
373
573
  else
374
- free.each do |path, verb, template|
574
+ free.each do |path, verb, template, defaults|
375
575
  holder = (paths[path] ||= {})
376
- placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path)
576
+ placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path, defaults)
377
577
  holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
378
578
  slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
379
579
  end
@@ -495,14 +695,22 @@ module Permittable
495
695
  # `own` is the route's own spelling of the template, which differs
496
696
  # from `path` when collapse_shapes placed the route under another
497
697
  # route's spelling: the same shape, so the names pair up by position.
498
- def with_path_parameters(operation, path, rule, own = path)
698
+ #
699
+ # `defaults` names what the route fills from its `defaults:` (see
700
+ # route_defaults). The router merges those over the body like a
701
+ # templated name, so they leave the body the same way, but the URL does
702
+ # not carry them and they are no path parameter. A name the path
703
+ # templates is that parameter instead: `get "o(/:id)", defaults:
704
+ # { id: 5 }` is a parameter on `/o/{id}` and a default only on `/o`.
705
+ def with_path_parameters(operation, path, rule, own = path, defaults = [])
499
706
  # A name the path templates twice (a mount and an engine route both
500
707
  # name `:id`) is declared once: OpenAPI forbids two parameters with
501
708
  # one name and location.
502
709
  variables = path.scan(/\{(\w+)\}/).flatten.uniq
503
- return operation if variables.empty?
504
-
505
710
  own_names = own.scan(/\{(\w+)\}/).flatten
711
+ supplied = Array(defaults) - own_names
712
+ return operation if variables.empty? && supplied.empty?
713
+
506
714
  fields = path_fields(rule, path.scan(/\{(\w+)\}/).flatten.zip(own_names).uniq(&:first))
507
715
  parameters = variables.map do |name|
508
716
  schema = fields[name] ? path_schema(fields[name]) : { "type" => "string" }
@@ -512,8 +720,8 @@ module Permittable
512
720
  # it; emission stays deterministic either way.
513
721
  operation.each_with_object({}) do |(key, value), out|
514
722
  if key == "requestBody"
515
- out["parameters"] = parameters
516
- value = body_without(rule, fields.values, own_names)
723
+ out["parameters"] = parameters unless parameters.empty?
724
+ value = body_without(rule, fields.values + default_fields(rule, supplied), own_names + supplied)
517
725
  end
518
726
  out[key] = value unless value.nil?
519
727
  end
@@ -541,6 +749,16 @@ module Permittable
541
749
  end.to_h
542
750
  end
543
751
 
752
+ # The rootless contract's fields for the names the route's `defaults:`
753
+ # fill. Unlike a path parameter's, any kind of field goes: the router
754
+ # overwrites the body's value whatever its shape, and no parameter has to
755
+ # carry it.
756
+ def default_fields(rule, names)
757
+ return [] if rule[:root]
758
+
759
+ rule[:fields].select { |f| names.include?(f[:name].to_s) }
760
+ end
761
+
544
762
  # A moved field's schema as the path parameter documents it. The URL
545
763
  # always supplies the segment, so a `default:` never applies and the
546
764
  # value is never null: `nullable:` would only add "null" to the type.
@@ -561,7 +779,8 @@ module Permittable
561
779
  # produces, so the schema must not forbid it. Each name the route
562
780
  # templates therefore stays in the body as an unconstrained property,
563
781
  # any value and never required, since the value is read from the URL and
564
- # nothing in the body can make the request wrong. A rooted contract
782
+ # nothing in the body can make the request wrong. A name the route's
783
+ # `defaults:` fill is treated the same. A rooted contract
565
784
  # needs none of this: its wrapper object is permissive already.
566
785
  def body_without(rule, moved, templated)
567
786
  rest = rule[:fields] - moved
@@ -570,7 +789,7 @@ module Permittable
570
789
  body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
571
790
  return body unless rule[:unknown] == :error && !rule[:root]
572
791
 
573
- properties = body["content"]["application/json"]["schema"]["properties"]
792
+ properties = body["content"].values.first["schema"]["properties"]
574
793
  templated.each { |name| properties[name] ||= {} }
575
794
  body
576
795
  end
@@ -757,6 +976,9 @@ module Permittable
757
976
  if engines && (mounted = mounted_routes(route)) && visiting.none? { |above| above.equal?(mounted) }
758
977
  found = collect_routes(mounted, engines: engines, prefixes: mount_prefixes(prefixes, route), parents: Array(id),
759
978
  visiting: visiting, reachable: reachable && unconditional?(route))
979
+ # What the mount fills reaches the engine's requests too.
980
+ mount_defaults = route_defaults(route)
981
+ found = found.map { |d| d.merge(defaults: (mount_defaults + Array(d[:defaults])).uniq) } unless mount_defaults.empty?
760
982
  verbs = route.verb.to_s.downcase.split("|")
761
983
  next verbs.empty? ? found : found.select { |descriptor| verbs.include?(descriptor[:verb]) }
762
984
  end
@@ -769,6 +991,7 @@ module Permittable
769
991
  verb = ALL_VERBS if verb.empty?
770
992
 
771
993
  variants = optional_variants(route.path.spec, requirements, prefixes)
994
+ defaults = route_defaults(route)
772
995
  # Read once per route; drop_shadowed reads it, then strips it.
773
996
  unconditional = reachable && unconditional?(route)
774
997
  # One route can answer several verbs (`match via: [:patch, :put]`, and
@@ -780,12 +1003,25 @@ module Permittable
780
1003
  variants.map do |path, shadows|
781
1004
  descriptor = { controller: requirements[:controller], action: requirements[:action],
782
1005
  verb: single.downcase, path: path, route: id, unconditional: unconditional }
1006
+ descriptor = descriptor.merge(defaults: defaults) unless defaults.empty?
783
1007
  shadows.empty? ? descriptor : descriptor.merge(shadows: shadows)
784
1008
  end
785
1009
  end
786
1010
  end
787
1011
  end
788
1012
 
1013
+ # The names a route fills from its `defaults:` (`scope`, `resources` and
1014
+ # `mount` take them too), as strings. The router puts them into
1015
+ # path_parameters beside the URL's own, so the client cannot set them;
1016
+ # `controller`, `action` and `format` are the router's own keys and
1017
+ # are left out, as the runtime leaves them out of `unknown:`. A
1018
+ # hand-built route that cannot say fills nothing.
1019
+ def route_defaults(route)
1020
+ return [] unless route.respond_to?(:defaults)
1021
+
1022
+ route.defaults.keys.map(&:to_s) - %w[controller action format]
1023
+ end
1024
+
789
1025
  # The Prefix of each variant of a mount's path under each of `prefixes`,
790
1026
  # outermost mount first, and within a mount present before absent, which
791
1027
  # is the order Rails' regexp tries them in. A variant is shadowed by every
@@ -453,7 +453,11 @@ module Permittable
453
453
  # proc cannot rewrite the spec's own literal (the same reason
454
454
  # validate_authored_value! copies before normalizing).
455
455
  own = expected.is_a?(String) ? expected.dup : expected
456
- status, value = Coercion.cast(field[:type], Coercion.apply_normalize(field[:normalize], own))
456
+ normalized = Coercion.apply_normalize(field[:normalize], own)
457
+ # Absent on a nullable field is the nil a contract stores for it.
458
+ return nil if field[:nullable] && Coercion.absent_value?(normalized)
459
+
460
+ status, value = Coercion.cast(field[:type], normalized)
457
461
  status == :ok ? value : expected
458
462
  when :array
459
463
  return expected unless expected.is_a?(Array)
@@ -749,7 +753,7 @@ module Permittable
749
753
  mirror_csrf_key(host)
750
754
  host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
751
755
  instance = host.new
752
- instance.params = @params
756
+ instance.params = request_params
753
757
  violations = instance.permittable_violations(action)
754
758
  result = violations.empty? ? instance.permitted_params(action) : nil
755
759
  [violations, result]
@@ -772,6 +776,52 @@ module Permittable
772
776
  host.define_method(:permittable_action_name) { action }
773
777
  end
774
778
 
779
+ # What a controller reads as `params`: the payload after the
780
+ # normalization every request goes through, ActionDispatch's Utils.
781
+ # Under the default perform_deep_munge it removes nil
782
+ # from every array, so a JSON `{"tags": ["a", null]}` reaches the
783
+ # contract as ["a"], and `length:` never counts the null. Handing the
784
+ # raw payload over made reject_params(tags: ["a", nil]) pass for a
785
+ # request that is a 200. An app that turned perform_deep_munge off
786
+ # still sees the null, because the setting is read, not assumed.
787
+ # A copy goes in: the normalizer rewrites arrays in place, and the
788
+ # payload belongs to the spec. The copy stringifies Symbol keys, which
789
+ # the normalizer's HashWithIndifferentAccess does anyway but only after
790
+ # it has read a Symbol :tempfile key as an upload, a key a request
791
+ # (String keys only) never has. A Parameters payload is unwrapped first
792
+ # so it is normalized like the Hash it wraps.
793
+ def request_params
794
+ utils = request_utils
795
+ return @params unless utils
796
+
797
+ payload = @params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params
798
+ utils.normalize_encode_params(copy_payload(payload))
799
+ end
800
+
801
+ # Rails main moved the class from ActionDispatch::Request::Utils to
802
+ # ActionDispatch::Http::Utils. nil only when ActionDispatch is not
803
+ # loaded at all (a non-Rails stand-in controller); once it is, a rename
804
+ # this does not know raises instead of silently skipping the
805
+ # normalization, which would bring the false greens back.
806
+ def request_utils
807
+ return unless defined?(ActionDispatch)
808
+
809
+ if defined?(ActionDispatch::Http::Utils) then ActionDispatch::Http::Utils
810
+ elsif defined?(ActionDispatch::Request::Utils) then ActionDispatch::Request::Utils
811
+ else
812
+ raise "Permittable's matchers cannot find Rails' parameter normalizer: neither " \
813
+ "ActionDispatch::Http::Utils nor ActionDispatch::Request::Utils is defined"
814
+ end
815
+ end
816
+
817
+ def copy_payload(value)
818
+ case value
819
+ when Hash then value.to_h { |key, item| [key.is_a?(Symbol) ? key.to_s : key, copy_payload(item)] }
820
+ when Array then value.map { |item| copy_payload(item) }
821
+ else value
822
+ end
823
+ end
824
+
775
825
  def run_contract
776
826
  result = @subject.call(@params)
777
827
  [result.violations, result.params]
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.13.0".freeze
2
+ VERSION = "0.14.0".freeze
3
3
  end