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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +40 -11
- data/lib/permittable/authored_values.rb +37 -1
- data/lib/permittable/column_guard.rb +33 -0
- data/lib/permittable/error_envelope.rb +21 -4
- data/lib/permittable/json_schema.rb +8 -2
- data/lib/permittable/open_api.rb +248 -12
- data/lib/permittable/rspec.rb +52 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +301 -22
- metadata +2 -2
data/lib/permittable/open_api.rb
CHANGED
|
@@ -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" => {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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"]["
|
|
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
|
data/lib/permittable/rspec.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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]
|
data/lib/permittable/version.rb
CHANGED