grape 3.3.4 → 4.0.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/CONTRIBUTING.md +16 -0
  4. data/README.md +208 -8
  5. data/UPGRADING.md +756 -6
  6. data/lib/grape/api/instance.rb +40 -33
  7. data/lib/grape/content_types.rb +47 -1
  8. data/lib/grape/dry_types.rb +1 -1
  9. data/lib/grape/dsl/callbacks.rb +3 -9
  10. data/lib/grape/dsl/declared.rb +4 -4
  11. data/lib/grape/dsl/desc.rb +18 -8
  12. data/lib/grape/dsl/entity.rb +37 -19
  13. data/lib/grape/dsl/helpers.rb +3 -3
  14. data/lib/grape/dsl/inside_route.rb +20 -8
  15. data/lib/grape/dsl/middleware.rb +3 -3
  16. data/lib/grape/dsl/parameters.rb +54 -28
  17. data/lib/grape/dsl/request_response.rb +37 -39
  18. data/lib/grape/dsl/rescue_options.rb +6 -5
  19. data/lib/grape/dsl/routing.rb +116 -49
  20. data/lib/grape/dsl/settings.rb +1 -1
  21. data/lib/grape/dsl/validations.rb +3 -3
  22. data/lib/grape/dsl/version_options.rb +3 -3
  23. data/lib/grape/endpoint/options.rb +13 -7
  24. data/lib/grape/endpoint.rb +141 -118
  25. data/lib/grape/env.rb +7 -0
  26. data/lib/grape/error_formatter/base.rb +6 -5
  27. data/lib/grape/error_formatter.rb +6 -2
  28. data/lib/grape/exceptions/error_response.rb +4 -1
  29. data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
  30. data/lib/grape/exceptions/validation.rb +9 -8
  31. data/lib/grape/formatter/json.rb +1 -0
  32. data/lib/grape/formatter/serializable_hash.rb +1 -0
  33. data/lib/grape/locale/en.yml +1 -0
  34. data/lib/grape/middleware/auth/dsl.rb +5 -33
  35. data/lib/grape/middleware/error.rb +153 -46
  36. data/lib/grape/middleware/formatter.rb +95 -26
  37. data/lib/grape/middleware/precomputed_content_types.rb +12 -7
  38. data/lib/grape/middleware/stack.rb +13 -4
  39. data/lib/grape/middleware/versioner/base.rb +3 -9
  40. data/lib/grape/middleware/versioner/path.rb +49 -7
  41. data/lib/grape/namespace.rb +3 -2
  42. data/lib/grape/path.rb +8 -69
  43. data/lib/grape/precompiled_json.rb +50 -0
  44. data/lib/grape/request.rb +36 -4
  45. data/lib/grape/router/base_route.rb +47 -10
  46. data/lib/grape/router/greedy_route.rb +5 -1
  47. data/lib/grape/router/pattern/path.rb +78 -0
  48. data/lib/grape/router/pattern.rb +60 -14
  49. data/lib/grape/router/route.rb +62 -10
  50. data/lib/grape/router.rb +100 -57
  51. data/lib/grape/serve_stream/file_body.rb +6 -0
  52. data/lib/grape/serve_stream/stream_response.rb +6 -0
  53. data/lib/grape/util/api_description.rb +10 -1
  54. data/lib/grape/util/cache.rb +21 -2
  55. data/lib/grape/util/deep_freeze.rb +1 -2
  56. data/lib/grape/util/freeze_on_new.rb +20 -0
  57. data/lib/grape/util/inheritable_setting.rb +751 -37
  58. data/lib/grape/util/media_type.rb +10 -3
  59. data/lib/grape/util/path_normalizer.rb +12 -9
  60. data/lib/grape/util/registry.rb +12 -2
  61. data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
  62. data/lib/grape/util/stackable_values.rb +32 -6
  63. data/lib/grape/validations/attributes_iterator.rb +21 -4
  64. data/lib/grape/validations/contract_scope.rb +8 -7
  65. data/lib/grape/validations/oneof_collector.rb +6 -10
  66. data/lib/grape/validations/params_documentation.rb +2 -2
  67. data/lib/grape/validations/params_scope.rb +66 -26
  68. data/lib/grape/validations/single_attribute_iterator.rb +5 -3
  69. data/lib/grape/validations/types/array_coercer.rb +4 -6
  70. data/lib/grape/validations/types/custom_type_coercer.rb +7 -1
  71. data/lib/grape/validations/types/dry_type_coercer.rb +3 -1
  72. data/lib/grape/validations/types/json.rb +1 -3
  73. data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
  74. data/lib/grape/validations/types/primitive_coercer.rb +12 -9
  75. data/lib/grape/validations/types/variant_collection_coercer.rb +8 -3
  76. data/lib/grape/validations/types.rb +5 -5
  77. data/lib/grape/validations/validations_spec.rb +14 -4
  78. data/lib/grape/validations/validators/base.rb +4 -7
  79. data/lib/grape/validations/validators/coerce_validator.rb +8 -2
  80. data/lib/grape/validations/validators/contract_scope_validator.rb +2 -1
  81. data/lib/grape/validations/validators/length_validator.rb +4 -2
  82. data/lib/grape/validations/validators/oneof_validator.rb +2 -0
  83. data/lib/grape/validations/validators/same_as_validator.rb +1 -0
  84. data/lib/grape/version.rb +2 -1
  85. data/lib/grape.rb +23 -2
  86. metadata +10 -9
  87. data/lib/grape/middleware/deprecated_options_hash_access.rb +0 -19
  88. data/lib/grape/util/base_inheritable.rb +0 -43
  89. data/lib/grape/util/inheritable_values.rb +0 -33
  90. data/lib/grape/util/reverse_stackable_values.rb +0 -15
@@ -7,6 +7,10 @@ module Grape
7
7
 
8
8
  # based on the HTTP Accept header with the pattern:
9
9
  # application/vnd.:vendor-:version+:format
10
+ #
11
+ # Matched against a down-cased media type: they are case-insensitive
12
+ # (RFC 9110 §8.3.1), while a vendor and version are declared in the DSL
13
+ # in the case they will be compared in.
10
14
  VENDOR_VERSION_HEADER_REGEX = /\Avnd\.(?<vendor>[a-z0-9.\-_!^]+?)(?:-(?<version>[a-z0-9*.]+))?(?:\+(?<format>[a-z0-9*\-.]+))?\z/
11
15
 
12
16
  def initialize(type:, subtype:)
@@ -41,7 +45,7 @@ module Grape
41
45
  def parse(media_type)
42
46
  return if media_type.blank?
43
47
 
44
- type, subtype = media_type.split('/', 2)
48
+ type, subtype = media_type.downcase.split('/', 2)
45
49
  return if type.blank? || subtype.blank?
46
50
 
47
51
  new(type:, subtype:)
@@ -50,14 +54,17 @@ module Grape
50
54
  def match?(media_type)
51
55
  return false if media_type.blank?
52
56
 
53
- subtype = media_type.split('/', 2).last
57
+ subtype = media_type.downcase.split('/', 2).last
54
58
  return false if subtype.blank?
55
59
 
56
60
  VENDOR_VERSION_HEADER_REGEX.match?(subtype)
57
61
  end
58
62
 
63
+ # The available types are registered in lower case and Rack matches them
64
+ # literally, so the header has to be down-cased to be compared against
65
+ # them at all.
59
66
  def best_quality_media_type(header, available_media_types)
60
- header.blank? ? available_media_types.first : Rack::Utils.best_q_match(header, available_media_types)
67
+ header.blank? ? available_media_types.first : Rack::Utils.best_q_match(header.downcase, available_media_types)
61
68
  end
62
69
  end
63
70
 
@@ -14,20 +14,23 @@ module Grape
14
14
  return '/' unless path
15
15
  return path if path == '/'
16
16
 
17
- # Fast path for the overwhelming majority of paths that don't need to be normalized
18
- return path if path.start_with?('/') && !(path.end_with?('/') || path.match?(%r{%|//}))
17
+ # Fast path for the overwhelming majority of paths that don't need to be
18
+ # normalized. Two String#include? calls rather than one `%|//` regexp:
19
+ # same predicate, and the scan stays in C without building a match.
20
+ return path if path.start_with?('/') && !(path.end_with?('/') || path.include?('%') || path.include?('//'))
19
21
 
20
- # Slow path
22
+ # Slow path. The bangs below are safe because +normalized+ is a fresh
23
+ # String built here, never the one the caller passed in.
21
24
  encoding = path.encoding
22
- path = "/#{path}"
23
- path.squeeze!('/')
25
+ normalized = "/#{path}"
26
+ normalized.squeeze!('/')
24
27
 
25
- unless path == '/'
26
- path.delete_suffix!('/')
27
- path.gsub!(/(%[a-f0-9]{2})/) { ::Regexp.last_match(1).upcase }
28
+ unless normalized == '/'
29
+ normalized.delete_suffix!('/')
30
+ normalized.gsub!(/(%[a-f0-9]{2})/) { ::Regexp.last_match(1).upcase }
28
31
  end
29
32
 
30
- path.force_encoding(encoding)
33
+ normalized.force_encoding(encoding)
31
34
  end
32
35
  end
33
36
  end
@@ -8,7 +8,7 @@ module Grape
8
8
  return if short_name.nil?
9
9
 
10
10
  warn "#{short_name} is already registered with class #{registry[short_name]}. It will be overridden globally with the following: #{klass.name}" if registry.key?(short_name)
11
- registry[short_name] = klass
11
+ registry[short_name] = registry[short_name.to_sym] = klass
12
12
  end
13
13
 
14
14
  private
@@ -19,8 +19,18 @@ module Grape
19
19
  klass.name.demodulize.underscore
20
20
  end
21
21
 
22
+ # Every registration under both spellings in one plain Hash, so a lookup
23
+ # is a single +Hash#[]+. A +HashWithIndifferentAccess+ converted the key
24
+ # on every read instead -- and these registries are read on the request
25
+ # path, by +Grape::Formatter+ once per response, by +Grape::Parser+ once
26
+ # per parsed body and by +Grape::ParamsBuilder+ once per params build,
27
+ # always with a Symbol, which is the spelling +convert_key+ allocates a
28
+ # String for.
29
+ #
30
+ # +register+ derives the short name as a String, so the Symbol is the
31
+ # alias.
22
32
  def registry
23
- @registry ||= ActiveSupport::HashWithIndifferentAccess.new
33
+ @registry ||= {}
24
34
  end
25
35
  end
26
36
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Util
5
+ # Diagnostics for +rescue_from+ registrations that can never run.
6
+ #
7
+ # Middleware::Error resolves with +find+, so within a scope the first
8
+ # matching class wins and one registered for a class an earlier handler
9
+ # already covers is dead code — silently, before this warned:
10
+ #
11
+ # rescue_from StandardError do ... end # wins
12
+ # rescue_from ArgumentError do ... end # never runs
13
+ #
14
+ # Warn rather than reorder: which should win is the author's call, and
15
+ # +rescue_from :all+ (consulted only after the registered handlers) already
16
+ # offers "broad first, specific still wins" to anyone who wants it.
17
+ module ShadowedRescueHandlers
18
+ module_function
19
+
20
+ # @param registered [Hash] the scope's own handlers, in registration order
21
+ # @param mapping [Hash] the handlers being registered now
22
+ # @return [void]
23
+ #
24
+ # Only a scope's own registrations are compared: across scopes the nearest
25
+ # one deliberately wins, so an inner +rescue_from StandardError+ shadowing
26
+ # an outer +rescue_from ArgumentError+ is the documented behaviour rather
27
+ # than a mistake. Classes sharing a handler object are skipped too, since
28
+ # +rescue_from A, B+ registers one handler for both and the entry that
29
+ # loses to the other changes nothing.
30
+ def warn_about(registered, mapping)
31
+ return if registered.empty?
32
+
33
+ mapping.each do |klass, handler|
34
+ covered_by, = registered.find { |already, existing| klass <= already && !existing.equal?(handler) }
35
+ next unless covered_by
36
+
37
+ warn(message_for(klass, covered_by))
38
+ end
39
+ end
40
+
41
+ def message_for(klass, covered_by)
42
+ return "Grape: rescue_from #{klass} was already registered in this scope; the first handler is kept and this one will never run." if klass == covered_by
43
+
44
+ "Grape: rescue_from #{klass} will never run — #{covered_by} was registered earlier in the same scope " \
45
+ 'and is matched first. Register the more specific class before the broader one.'
46
+ end
47
+ end
48
+ end
49
+ end
@@ -2,10 +2,36 @@
2
2
 
3
3
  module Grape
4
4
  module Util
5
- class StackableValues < BaseInheritable
5
+ # A read-only view of one settings scope's stackable registrations.
6
+ #
7
+ # Grape stores nothing here anymore: the registrations live on
8
+ # Grape::Util::InheritableSetting, one Array per key per scope, and are
9
+ # reached through its semantic accessors (+helpers+, +middleware+,
10
+ # +namespaces+, ...). This class survives only because grape-swagger
11
+ # reads InheritableSetting#namespace_stackable directly — including
12
+ # walking the #inherited_values chain and reading each level's own
13
+ # #new_values to recover per-scope registrations — and is expected to be
14
+ # removed once grape-swagger reads those accessors instead.
15
+ #
16
+ # Instances are built on demand by InheritableSetting#namespace_stackable
17
+ # and are not the backing store: writing to #new_values does not register
18
+ # anything.
19
+ class StackableValues
6
20
  EMPTY = [].freeze
7
21
 
8
- # Even if there is no value, an empty (frozen) array will be returned.
22
+ attr_reader :inherited_values, :new_values
23
+
24
+ # @param new_values [Hash, nil] the scope's own registrations, one Array
25
+ # per key; nil when the scope never registered anything.
26
+ # @param inherited_values [StackableValues, Hash] the enclosing scope's
27
+ # view, or an empty Hash at the root of the chain.
28
+ def initialize(new_values, inherited_values)
29
+ @new_values = new_values
30
+ @inherited_values = inherited_values
31
+ end
32
+
33
+ # Outermost scope first. Even if there is no value, an empty (frozen)
34
+ # array will be returned.
9
35
  def [](name)
10
36
  inherited_value = @inherited_values[name]
11
37
  new_value = @new_values && @new_values[name]
@@ -15,10 +41,10 @@ module Grape
15
41
  concat_values(inherited_value, new_value)
16
42
  end
17
43
 
18
- def []=(name, value)
19
- @new_values ||= {}
20
- @new_values[name] ||= []
21
- @new_values[name].push value
44
+ def keys
45
+ return @inherited_values.keys if @new_values.nil? || @new_values.empty?
46
+
47
+ (@inherited_values.keys + @new_values.keys).uniq
22
48
  end
23
49
 
24
50
  def to_hash
@@ -10,10 +10,22 @@ module Grape
10
10
  def initialize(attrs, scope)
11
11
  @attrs = attrs
12
12
  @scope = scope
13
+ # How many times #do_each may descend into a nested array. The
14
+ # declaration allows one level per Array-typed scope on the chain, less
15
+ # the one +Array.wrap+ already consumes in #each. Anything deeper was
16
+ # put there by the request, not by the declaration.
17
+ @max_nesting = [scope.array_depth - 1, 0].max
13
18
  end
14
19
 
15
20
  def each(params, &)
16
21
  original_params = @scope.params(params)
22
+ # A scope resolves to a Hash unless the declaration nests arrays, and
23
+ # then #do_each has nothing to do but hand it straight back: Array.wrap
24
+ # boxes it, the loop unboxes it on its only iteration, and with no Array
25
+ # anywhere neither the nesting descent nor the index bookkeeping
26
+ # applies. Every validator on a flat +params+ block comes through here.
27
+ return yield_attributes(original_params, &) if original_params.is_a?(Hash) && !@scope.iterates_elements?
28
+
17
29
  # because we need recursion for nested arrays
18
30
  do_each(Array.wrap(original_params), original_params, &)
19
31
  end
@@ -24,19 +36,24 @@ module Grape
24
36
  params_to_process.each_with_index do |resource_params, index|
25
37
  # when we get arrays of arrays it means that target element located inside array
26
38
  # we need this because we want to know parent arrays indices
27
- if resource_params.is_a?(Array)
39
+ #
40
+ # Only descend as far as the declaration nests. A request that wraps
41
+ # its elements deeper than that is yielded as-is, so the attribute
42
+ # validators see a non-hash and fail it the same way any other
43
+ # unexpected element type does.
44
+ if resource_params.is_a?(Array) && parent_indices.size < @max_nesting
28
45
  do_each(resource_params, original_params, [index] + parent_indices, &block)
29
46
  next
30
47
  end
31
48
 
32
- if @scope.type == Array
49
+ if @scope.iterates_elements?
33
50
  next unless original_params.is_a?(Array) # do not validate content of array if it isn't array
34
51
 
35
52
  store_indices(@scope, index, parent_indices)
36
53
  elsif original_params.is_a?(Array)
37
54
  # Lateral scope (no @element) whose params resolved to an array —
38
- # delegate index tracking to the nearest array-typed ancestor so
39
- # that full_name produces the correct bracketed index.
55
+ # delegate index tracking to the nearest element-iterating ancestor
56
+ # so that full_name produces the correct bracketed index.
40
57
  target = @scope.nearest_array_ancestor
41
58
  store_indices(target, index, parent_indices) if target
42
59
  end
@@ -9,19 +9,20 @@ module Grape
9
9
  # @yield a block yielding a new schema class. Optional.
10
10
  def initialize(api, contract = nil, &block)
11
11
  # When block is passed, the first arg is either schema or nil.
12
- contract = Dry::Schema.Params(parent: contract, &block) if block
12
+ declared = block ? Dry::Schema.Params(parent: contract, &block) : contract
13
13
 
14
- if contract.respond_to?(:schema)
14
+ if declared.respond_to?(:schema)
15
15
  # It's a Dry::Validation::Contract, then.
16
- contract = contract.new
17
- key_map = contract.schema.key_map
16
+ schema = declared.new
17
+ key_map = schema.schema.key_map
18
18
  else
19
19
  # Dry::Schema::Processor, hopefully.
20
- key_map = contract.key_map
20
+ schema = declared
21
+ key_map = declared.key_map
21
22
  end
22
23
 
23
- api.inheritable_setting.namespace_stackable[:contract_key_map] = key_map
24
- api.inheritable_setting.namespace_stackable[:validations] = Validators::ContractScopeValidator.new(schema: contract)
24
+ api.inheritable_setting.add_contract_key_map(key_map)
25
+ api.inheritable_setting.add_validation(Validators::ContractScopeValidator.new(schema:))
25
26
  end
26
27
  end
27
28
  end
@@ -8,31 +8,27 @@ module Grape
8
8
  # the real API. Exposes only the slice of the API surface that
9
9
  # ParamsScope and its helpers touch during definition.
10
10
  class OneofCollector
11
+ extend Forwardable
12
+
11
13
  attr_reader :inheritable_setting
12
14
 
15
+ def_delegator :@inheritable_setting, :validations
16
+
13
17
  def initialize
14
18
  @inheritable_setting = Grape::Util::InheritableSetting.new
15
- @inheritable_setting.namespace_inheritable[:do_not_document] = true
19
+ @inheritable_setting.do_not_document!
16
20
  end
17
21
 
18
22
  def configuration
19
23
  nil
20
24
  end
21
25
 
22
- def validators
23
- inheritable_setting.namespace_stackable[:validations]
24
- end
25
-
26
- def declared_params
27
- inheritable_setting.namespace_stackable[:declared_params]
28
- end
29
-
30
26
  # Evaluate +variant_block+ in a fresh +ParamsScope+ backed by a new
31
27
  # collector and return the validators that the block registered.
32
28
  def self.collect(variant_block)
33
29
  collector = new
34
30
  ParamsScope.new(api: collector, type: Hash, &variant_block)
35
- collector.validators
31
+ collector.validations
36
32
  end
37
33
  end
38
34
  end
@@ -6,12 +6,12 @@ module Grape
6
6
  # +ValidationsSpec+; never mutates the user's validations hash.
7
7
  module ParamsDocumentation
8
8
  def document_params(attrs, spec)
9
- return if @api.inheritable_setting.namespace_inheritable[:do_not_document]
9
+ return if @api.inheritable_setting.do_not_document?
10
10
 
11
11
  documented_attrs = attrs.to_h do |name|
12
12
  [full_name(name), extract_details(spec)]
13
13
  end
14
- @api.inheritable_setting.namespace_stackable[:params] = documented_attrs
14
+ @api.inheritable_setting.add_params_documentation(documented_attrs)
15
15
  end
16
16
 
17
17
  private
@@ -3,7 +3,7 @@
3
3
  module Grape
4
4
  module Validations
5
5
  class ParamsScope
6
- attr_reader :parent, :type, :nearest_array_ancestor, :full_path
6
+ attr_reader :parent, :type, :nearest_array_ancestor, :array_depth, :full_path
7
7
 
8
8
  def qualifying_params
9
9
  ParamScopeTracker.current&.qualifying_params(self)
@@ -78,6 +78,9 @@ module Grape
78
78
  # configure_declared_params consumes it and clears @declared_params to nil.
79
79
  @declared_params = []
80
80
  @full_path = build_full_path
81
+ # Read by the validators instantiated from the block below, so it has to
82
+ # be settled before the instance_eval.
83
+ @array_depth = find_array_depth
81
84
 
82
85
  instance_eval(&block) if block
83
86
 
@@ -169,6 +172,17 @@ module Grape
169
172
  @parent && @element
170
173
  end
171
174
 
175
+ # Whether this scope's params resolve to one entry per element, which is
176
+ # what makes both an element index and a nesting level meaningful.
177
+ #
178
+ # +type: Array[JSON]+ counts as much as +type: Array+ does. It is easy to
179
+ # miss because it evaluates to the Array *instance* +[JSON]+ rather than
180
+ # the Array class, so an +== Array+ test quietly excluded it.
181
+ # @return [Boolean]
182
+ def iterates_elements?
183
+ @type == Array || @type == SPECIAL_JSON.last
184
+ end
185
+
172
186
  # A lateral scope is subordinate to its parent, but its keys are at the
173
187
  # same level as its parent and thus is not contained within an element.
174
188
  # @return [Boolean] whether or not this scope is lateral
@@ -186,12 +200,16 @@ module Grape
186
200
 
187
201
  # Adds a parameter declaration to our list of validations.
188
202
  # @param attrs [Array] (see Grape::DSL::Parameters#requires)
189
- def push_declared_params(attrs, **opts)
190
- opts[:declared_params_scope] = self unless opts.key?(:declared_params_scope)
191
- return @parent.push_declared_params(attrs, **opts) if lateral?
203
+ # +declared_params_scope+ is the scope an attribute is recorded against.
204
+ # It defaults to the receiver and is forwarded unchanged when a lateral
205
+ # scope hands the push to its parent, so an attribute declared inside a
206
+ # +given+ block stays attributed to the lateral scope that declared it
207
+ # rather than to the parent that stores it.
208
+ def push_declared_params(attrs, as: nil, declared_params_scope: self)
209
+ return @parent.push_declared_params(attrs, as:, declared_params_scope:) if lateral?
192
210
 
193
- push_renamed_param(full_path + [attrs.first], opts[:as]) if opts[:as]
194
- @declared_params.concat(attrs.map { |attr| ::Grape::Validations::ParamsScope::Attr.new(attr, opts[:declared_params_scope]) })
211
+ push_renamed_param(full_path + [attrs.first], as) if as
212
+ @declared_params.concat(attrs.map { |attr| ::Grape::Validations::ParamsScope::Attr.new(attr, declared_params_scope) })
195
213
  end
196
214
 
197
215
  private
@@ -211,10 +229,7 @@ module Grape
211
229
  # @param new_name [String, Symbol] the new name of the parameter (the
212
230
  # renamed name, with the +as: ...+ semantic)
213
231
  def push_renamed_param(path, new_name)
214
- api_route_setting = @api.inheritable_setting.route
215
- base = api_route_setting[:renamed_params] || {}
216
- base[Array(path).map(&:to_s)] = new_name.to_s
217
- api_route_setting[:renamed_params] = base
232
+ @api.inheritable_setting.add_route_renamed_param(Array(path).map(&:to_s), new_name.to_s)
218
233
  end
219
234
 
220
235
  def require_required_and_optional_fields(context, using:, except: nil)
@@ -230,7 +245,7 @@ module Grape
230
245
  end
231
246
  required_fields.each do |field|
232
247
  field_opts = using[field]
233
- raise ArgumentError, "required field not exist: #{field}" unless field_opts
248
+ raise ArgumentError, "required field not exist: `#{field}`" unless field_opts
234
249
 
235
250
  requires(field, **field_opts)
236
251
  end
@@ -252,11 +267,6 @@ module Grape
252
267
  end
253
268
  end
254
269
 
255
- def validate_attributes(attrs, **opts, &block)
256
- opts[:type] ||= Array if block
257
- validates(attrs, opts)
258
- end
259
-
260
270
  # Returns a new parameter scope, subordinate to the current one and nested
261
271
  # under the given element.
262
272
  # @param element [Symbol] the parameter name under which this scope is nested
@@ -266,8 +276,11 @@ module Grape
266
276
  # is optional or not (and hence, whether this block's params will be).
267
277
  # @yield parameter scope
268
278
  def new_scope(element, type:, as:, optional: false, &)
269
- # if required params are grouped and no type or unsupported type is provided, raise an error
270
- if element && !optional
279
+ # A group needs a type: it says whether the nested params sit under one
280
+ # object or repeat in a list, and without it the `type || Array` below
281
+ # would quietly pick one. Checked here for `requires` and `optional`
282
+ # alike — `new_scope` is only reached from their block branch.
283
+ if element
271
284
  raise Grape::Exceptions::MissingGroupType if type.nil?
272
285
  raise Grape::Exceptions::UnsupportedGroupType unless Grape::Validations::Types.group?(type)
273
286
  end
@@ -316,20 +329,44 @@ module Grape
316
329
  push_renamed_param(full_path, @element_renamed) if @element_renamed
317
330
  return @parent.push_declared_params [{ @element => @declared_params }] if nested?
318
331
 
319
- @api.inheritable_setting.namespace_stackable[:declared_params] = @declared_params
332
+ @api.inheritable_setting.add_declared_params(@declared_params)
320
333
  ensure
321
334
  @declared_params = nil
322
335
  end
323
336
 
324
337
  def find_nearest_array_ancestor
325
338
  scope = @parent
326
- scope = scope.parent while scope && scope.type != Array
339
+ scope = scope.parent while scope && !scope.iterates_elements?
327
340
  scope
328
341
  end
329
342
 
330
- def validates(attrs, validations)
331
- process_oneof!(validations) if validations.key?(:oneof)
332
- spec = ValidationsSpec.from(validations)
343
+ # Every element-iterating scope on the chain adds one level of nesting to
344
+ # what {#params} returns, because +map_params+ maps over the array it
345
+ # resolved from the parent. Counting them tells {AttributesIterator} how
346
+ # deep the declaration says the params for this scope may legitimately be.
347
+ def find_array_depth
348
+ (iterates_elements? ? 1 : 0) + (@parent&.array_depth || 0)
349
+ end
350
+
351
+ # +required+ is the DSL's own signal — +requires+ passes true, +optional+
352
+ # false. It used to travel as a +:presence+ key that +requires+ wrote into
353
+ # the caller's option Hash, which is why a user-supplied +presence:+ was
354
+ # silently overwritten there and silently honoured by +optional+, where
355
+ # nothing overwrote it. The key is built here from the flag instead, after
356
+ # the caller has merged in any enclosing +with+ attributes, so a
357
+ # group-level +message:+ reaches the presence validator.
358
+ #
359
+ # A +presence:+ supplied by an API still decides the outcome exactly as it
360
+ # used to — deprecated rather than dropped, so nothing changes under an
361
+ # API that relies on it until the key is ignored outright.
362
+ def validates(attrs, validations, required: false)
363
+ if validations.key?(:presence)
364
+ Grape.deprecator.warn('Passing a `presence` option is deprecated and it will be ignored in a future release. Declare the parameter with `requires` to make it required, `optional` to make it optional.')
365
+ end
366
+
367
+ declared = required ? validations.merge(presence: { value: true, message: validations[:message] }) : validations
368
+ declared = declared.merge(oneof: collected_oneof(declared)) if declared.key?(:oneof)
369
+ spec = ValidationsSpec.from(declared)
333
370
 
334
371
  document_params(attrs, spec)
335
372
 
@@ -381,14 +418,17 @@ module Grape
381
418
  # {OneofCollector} so the full params DSL is available inside variants
382
419
  # and the resulting validators are kept out of the real API's
383
420
  # registration list.
384
- def process_oneof!(validations)
421
+ # Returns the collected variants rather than writing them back into
422
+ # +validations+, which is the options Hash the +requires+/+optional+ call
423
+ # site built.
424
+ def collected_oneof(validations)
385
425
  raise ArgumentError, 'oneof: requires type: Hash' unless validations[:type] == Hash
386
426
 
387
427
  variants = validations[:oneof]
388
428
  raise ArgumentError, 'oneof: must be a non-empty Array of blocks' unless variants.is_a?(Array) && variants.any?
389
429
  raise ArgumentError, 'oneof: each variant must be a Proc' unless variants.all?(Proc)
390
430
 
391
- validations[:oneof] = variants.map { |block| OneofCollector.collect(block) }
431
+ variants.map { |block| OneofCollector.collect(block) }
392
432
  end
393
433
 
394
434
  def validate(type, options, attrs, required, opts)
@@ -400,7 +440,7 @@ module Grape
400
440
  self,
401
441
  opts
402
442
  )
403
- @api.inheritable_setting.namespace_stackable[:validations] = validator_instance
443
+ @api.inheritable_setting.add_validation(validator_instance)
404
444
  end
405
445
 
406
446
  def all_element_blank?(scoped_params)
@@ -8,9 +8,11 @@ module Grape
8
8
  def yield_attributes(val)
9
9
  return if skip?(val)
10
10
 
11
- @attrs.each do |attr_name|
12
- yield val, attr_name, empty?(val)
13
- end
11
+ # The emptiness of the scope's params is the same answer for every
12
+ # attribute in it, and answering it costs a +respond_to?+, so it is
13
+ # asked once for the whole list rather than once per attribute.
14
+ empty_val = empty?(val)
15
+ @attrs.each { |attr_name| yield val, attr_name, empty_val }
14
16
  end
15
17
 
16
18
  # Primitives like Integers and Booleans don't respond to +empty?+.
@@ -15,7 +15,9 @@ module Grape
15
15
  def initialize(type, strict: false)
16
16
  super
17
17
  @coercer = strict ? DryTypes::Strict::Array : DryTypes::Params::Array
18
- @subtype = type.first
18
+ # Built eagerly: instances are shared across requests (and cached in
19
+ # Types::CoercerCache), so no lazy state may be created at call time.
20
+ @elem_coercer = DryTypeCoercer.coercer_instance_for(type.first, strict:)
19
21
  end
20
22
 
21
23
  def call(_val)
@@ -27,7 +29,7 @@ module Grape
27
29
 
28
30
  protected
29
31
 
30
- attr_reader :subtype
32
+ attr_reader :elem_coercer
31
33
 
32
34
  def coerce_elements(collection)
33
35
  return if collection.nil?
@@ -50,10 +52,6 @@ module Grape
50
52
  def reject?(val)
51
53
  val.nil?
52
54
  end
53
-
54
- def elem_coercer
55
- @elem_coercer ||= DryTypeCoercer.coercer_instance_for(subtype, strict:)
56
- end
57
55
  end
58
56
  end
59
57
  end
@@ -33,6 +33,8 @@ module Grape
33
33
  # contract as +coerced?+, and must be supplied with a coercion
34
34
  # +method+.
35
35
  class CustomTypeCoercer
36
+ extend Grape::Util::FreezeOnNew
37
+
36
38
  TYPE_CHECK_METHODS = %i[coerced? parsed?].freeze
37
39
  COLLECTION_TYPES = [Array, Set].freeze
38
40
  private_constant :TYPE_CHECK_METHODS, :COLLECTION_TYPES
@@ -88,8 +90,12 @@ module Grape
88
90
  ->(val) { method.call(val).deep_symbolize_keys }
89
91
  end
90
92
 
93
+ # +dup+ before +map!+: the collection is whatever the user's coercion
94
+ # method returned — possibly frozen, possibly the input itself — so we
95
+ # must not mutate it in place. +dup+ also preserves the class, keeping a
96
+ # +Set+ a +Set+ (unlike +map+, which would return an +Array+).
91
97
  def collection_symbolizer(method)
92
- ->(val) { method.call(val).map! { |item| symbolize_if_hash(item) } }
98
+ ->(val) { method.call(val).dup.map! { |item| symbolize_if_hash(item) } }
93
99
  end
94
100
 
95
101
  def symbolize_if_hash(item)
@@ -8,6 +8,8 @@ module Grape
8
8
  # but check its type. More information there
9
9
  # https://dry-rb.org/gems/dry-types/main/built-in-types/
10
10
  class DryTypeCoercer
11
+ extend Grape::Util::FreezeOnNew
12
+
11
13
  class << self
12
14
  # Returns a collection coercer which corresponds to a given type.
13
15
  # Example:
@@ -21,7 +23,7 @@ module Grape
21
23
  when Set
22
24
  SetCoercer
23
25
  else
24
- raise ArgumentError, "Unknown type: #{type}"
26
+ raise ArgumentError, "unknown type: `#{type}`"
25
27
  end
26
28
  end
27
29
 
@@ -17,9 +17,7 @@ module Grape
17
17
  # @return [Hash,Array<Hash>,nil]
18
18
  def parse(input)
19
19
  return input if parsed?(input)
20
-
21
- # Allow nulls and blank strings
22
- return if input.nil? || input.match?(/^\s*$/)
20
+ return if input.blank?
23
21
 
24
22
  JSON.parse(input, symbolize_names: true)
25
23
  end
@@ -13,6 +13,8 @@ module Grape
13
13
  # an allowed type it should be declared last, since it will always
14
14
  # successfully "coerce" the value.
15
15
  class MultipleTypeCoercer
16
+ extend Grape::Util::FreezeOnNew
17
+
16
18
  # Construct a new coercer that will attempt to coerce
17
19
  # values to the given list of types in the given order.
18
20
  #
@@ -28,7 +30,7 @@ module Grape
28
30
  else
29
31
  Types.build_coercer type, strict: !@method.nil?
30
32
  end
31
- end
33
+ end.freeze
32
34
  end
33
35
 
34
36
  # Coerces the given value.
@@ -39,12 +41,12 @@ module Grape
39
41
  # of {InvalidValue} if the value could not be coerced.
40
42
  def call(val)
41
43
  # once the value is coerced by the custom method, its type should be checked
42
- val = @method.call(val) if @method
44
+ candidate = @method ? @method.call(val) : val
43
45
 
44
46
  coerced_val = InvalidValue.new
45
47
 
46
48
  @type_coercers.each do |coercer|
47
- coerced_val = coercer.call(val)
49
+ coerced_val = coercer.call(candidate)
48
50
 
49
51
  return coerced_val unless coerced_val.is_a?(InvalidValue)
50
52
  end