grape 2.4.0 → 4.0.1

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +351 -0
  3. data/CONTRIBUTING.md +18 -10
  4. data/README.md +457 -228
  5. data/UPGRADING.md +1240 -1
  6. data/grape.gemspec +7 -7
  7. data/lib/grape/api/instance.rb +65 -116
  8. data/lib/grape/api.rb +53 -62
  9. data/lib/grape/content_types.rb +58 -3
  10. data/lib/grape/cookies.rb +2 -6
  11. data/lib/grape/declared_params_handler.rb +116 -0
  12. data/lib/grape/dry_types.rb +48 -4
  13. data/lib/grape/dsl/callbacks.rb +8 -58
  14. data/lib/grape/dsl/declared.rb +35 -0
  15. data/lib/grape/dsl/desc.rb +24 -67
  16. data/lib/grape/dsl/entity.rb +106 -0
  17. data/lib/grape/dsl/helpers.rb +80 -65
  18. data/lib/grape/dsl/inside_route.rb +56 -296
  19. data/lib/grape/dsl/logger.rb +4 -9
  20. data/lib/grape/dsl/middleware.rb +22 -40
  21. data/lib/grape/dsl/parameters.rb +86 -93
  22. data/lib/grape/dsl/request_response.rb +139 -139
  23. data/lib/grape/dsl/rescue_options.rb +25 -0
  24. data/lib/grape/dsl/routing.rb +328 -203
  25. data/lib/grape/dsl/settings.rb +29 -135
  26. data/lib/grape/dsl/validations.rb +38 -44
  27. data/lib/grape/dsl/version_options.rb +23 -0
  28. data/lib/grape/endpoint/options.rb +25 -0
  29. data/lib/grape/endpoint.rb +285 -245
  30. data/lib/grape/env.rb +8 -3
  31. data/lib/grape/error_formatter/base.rb +27 -21
  32. data/lib/grape/error_formatter/json.rb +6 -0
  33. data/lib/grape/error_formatter/txt.rb +10 -10
  34. data/lib/grape/error_formatter.rb +6 -2
  35. data/lib/grape/exceptions/base.rb +20 -44
  36. data/lib/grape/exceptions/error_response.rb +48 -0
  37. data/lib/grape/exceptions/incompatible_option_values.rb +1 -1
  38. data/lib/grape/exceptions/internal_server_error.rb +16 -0
  39. data/lib/grape/exceptions/invalid_accept_header.rb +1 -1
  40. data/lib/grape/exceptions/invalid_formatter.rb +1 -1
  41. data/lib/grape/exceptions/invalid_message_body.rb +1 -1
  42. data/lib/grape/exceptions/invalid_version_header.rb +1 -1
  43. data/lib/grape/exceptions/invalid_versioner_option.rb +1 -1
  44. data/lib/grape/exceptions/method_not_allowed.rb +1 -1
  45. data/lib/grape/exceptions/missing_group_type.rb +0 -2
  46. data/lib/grape/exceptions/missing_mime_type.rb +1 -1
  47. data/lib/grape/exceptions/request_error.rb +11 -0
  48. data/lib/grape/exceptions/unknown_auth_strategy.rb +1 -1
  49. data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
  50. data/lib/grape/exceptions/unknown_parameter.rb +1 -1
  51. data/lib/grape/exceptions/unknown_params_builder.rb +1 -1
  52. data/lib/grape/exceptions/unknown_validator.rb +1 -1
  53. data/lib/grape/exceptions/unsupported_group_type.rb +0 -2
  54. data/lib/grape/exceptions/validation.rb +25 -7
  55. data/lib/grape/exceptions/validation_array_errors.rb +4 -0
  56. data/lib/grape/exceptions/validation_errors.rb +21 -23
  57. data/lib/grape/formatter/json.rb +1 -0
  58. data/lib/grape/formatter/serializable_hash.rb +6 -9
  59. data/lib/grape/json.rb +38 -2
  60. data/lib/grape/locale/en.yml +3 -5
  61. data/lib/grape/middleware/auth/base.rb +4 -3
  62. data/lib/grape/middleware/auth/dsl.rb +9 -23
  63. data/lib/grape/middleware/base.rb +41 -36
  64. data/lib/grape/middleware/error.rb +271 -73
  65. data/lib/grape/middleware/formatter.rb +152 -63
  66. data/lib/grape/middleware/precomputed_content_types.rb +51 -0
  67. data/lib/grape/middleware/stack.rb +20 -11
  68. data/lib/grape/middleware/versioner/accept_version_header.rb +3 -3
  69. data/lib/grape/middleware/versioner/base.rb +40 -48
  70. data/lib/grape/middleware/versioner/header.rb +5 -23
  71. data/lib/grape/middleware/versioner/param.rb +2 -2
  72. data/lib/grape/middleware/versioner/path.rb +54 -7
  73. data/lib/grape/mountable.rb +22 -0
  74. data/lib/grape/namespace.rb +19 -11
  75. data/lib/grape/params_builder/base.rb +2 -0
  76. data/lib/grape/params_builder/hash_with_indifferent_access.rb +1 -1
  77. data/lib/grape/params_builder.rb +2 -19
  78. data/lib/grape/parser/json.rb +1 -1
  79. data/lib/grape/path.rb +8 -72
  80. data/lib/grape/precompiled_json.rb +50 -0
  81. data/lib/grape/request.rb +57 -20
  82. data/lib/grape/router/base_route.rb +53 -7
  83. data/lib/grape/router/greedy_route.rb +16 -6
  84. data/lib/grape/router/mustermann_pattern.rb +44 -0
  85. data/lib/grape/router/pattern/path.rb +78 -0
  86. data/lib/grape/router/pattern.rb +65 -37
  87. data/lib/grape/router/route.rb +66 -18
  88. data/lib/grape/router.rb +126 -115
  89. data/lib/grape/serve_stream/file_body.rb +7 -0
  90. data/lib/grape/serve_stream/sendfile_response.rb +3 -5
  91. data/lib/grape/serve_stream/stream_response.rb +7 -0
  92. data/lib/grape/testing.rb +33 -0
  93. data/lib/grape/util/api_description.rb +67 -0
  94. data/lib/grape/util/cache.rb +22 -2
  95. data/lib/grape/util/deep_freeze.rb +34 -0
  96. data/lib/grape/util/freeze_on_new.rb +20 -0
  97. data/lib/grape/util/inheritable_setting.rb +777 -39
  98. data/lib/grape/util/lazy/base.rb +16 -0
  99. data/lib/grape/util/lazy/block.rb +2 -9
  100. data/lib/grape/util/lazy/value.rb +2 -9
  101. data/lib/grape/util/lazy/value_enumerable.rb +13 -16
  102. data/lib/grape/util/media_type.rb +13 -9
  103. data/lib/grape/util/path_normalizer.rb +37 -0
  104. data/lib/grape/util/registry.rb +13 -3
  105. data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
  106. data/lib/grape/util/stackable_values.rb +39 -10
  107. data/lib/grape/util/translation.rb +42 -0
  108. data/lib/grape/validations/attributes_iterator.rb +61 -29
  109. data/lib/grape/validations/coerce_options.rb +21 -0
  110. data/lib/grape/validations/contract_scope.rb +8 -13
  111. data/lib/grape/validations/multiple_attributes_iterator.rb +1 -1
  112. data/lib/grape/validations/oneof_collector.rb +35 -0
  113. data/lib/grape/validations/param_scope_tracker.rb +62 -0
  114. data/lib/grape/validations/params_documentation.rb +52 -0
  115. data/lib/grape/validations/params_scope.rb +217 -304
  116. data/lib/grape/validations/shared_options.rb +19 -0
  117. data/lib/grape/validations/single_attribute_iterator.rb +6 -4
  118. data/lib/grape/validations/types/array_coercer.rb +7 -10
  119. data/lib/grape/validations/types/custom_type_coercer.rb +47 -85
  120. data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
  121. data/lib/grape/validations/types/dry_type_coercer.rb +10 -15
  122. data/lib/grape/validations/types/json.rb +1 -3
  123. data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
  124. data/lib/grape/validations/types/primitive_coercer.rb +14 -33
  125. data/lib/grape/validations/types/set_coercer.rb +1 -1
  126. data/lib/grape/validations/types/variant_collection_coercer.rb +16 -3
  127. data/lib/grape/validations/types.rb +29 -51
  128. data/lib/grape/validations/validations_spec.rb +164 -0
  129. data/lib/grape/validations/validators/all_or_none_of_validator.rb +6 -3
  130. data/lib/grape/validations/validators/allow_blank_validator.rb +10 -5
  131. data/lib/grape/validations/validators/at_least_one_of_validator.rb +5 -2
  132. data/lib/grape/validations/validators/base.rb +118 -34
  133. data/lib/grape/validations/validators/coerce_validator.rb +25 -37
  134. data/lib/grape/validations/validators/contract_scope_validator.rb +10 -5
  135. data/lib/grape/validations/validators/default_validator.rb +13 -20
  136. data/lib/grape/validations/validators/exactly_one_of_validator.rb +10 -3
  137. data/lib/grape/validations/validators/except_values_validator.rb +14 -4
  138. data/lib/grape/validations/validators/length_validator.rb +23 -22
  139. data/lib/grape/validations/validators/multiple_params_base.rb +12 -9
  140. data/lib/grape/validations/validators/{mutual_exclusion_validator.rb → mutually_exclusive_validator.rb} +4 -2
  141. data/lib/grape/validations/validators/oneof_validator.rb +51 -0
  142. data/lib/grape/validations/validators/presence_validator.rb +4 -2
  143. data/lib/grape/validations/validators/regexp_validator.rb +11 -3
  144. data/lib/grape/validations/validators/same_as_validator.rb +7 -15
  145. data/lib/grape/validations/validators/values_validator.rb +34 -26
  146. data/lib/grape/version.rb +1 -2
  147. data/lib/grape/xml.rb +8 -1
  148. data/lib/grape.rb +58 -31
  149. metadata +62 -39
  150. data/lib/grape/api/helpers.rb +0 -9
  151. data/lib/grape/dsl/api.rb +0 -17
  152. data/lib/grape/dsl/configuration.rb +0 -15
  153. data/lib/grape/exceptions/conflicting_types.rb +0 -11
  154. data/lib/grape/exceptions/empty_message_body.rb +0 -11
  155. data/lib/grape/exceptions/invalid_parameters.rb +0 -11
  156. data/lib/grape/exceptions/missing_option.rb +0 -11
  157. data/lib/grape/exceptions/too_deep_parameters.rb +0 -11
  158. data/lib/grape/exceptions/too_many_multipart_files.rb +0 -11
  159. data/lib/grape/exceptions/unknown_options.rb +0 -11
  160. data/lib/grape/extensions/active_support/hash_with_indifferent_access.rb +0 -24
  161. data/lib/grape/extensions/hash.rb +0 -27
  162. data/lib/grape/extensions/hashie/mash.rb +0 -24
  163. data/lib/grape/middleware/globals.rb +0 -14
  164. data/lib/grape/types/invalid_value.rb +0 -8
  165. data/lib/grape/util/base_inheritable.rb +0 -43
  166. data/lib/grape/util/inheritable_values.rb +0 -29
  167. data/lib/grape/util/reverse_stackable_values.rb +0 -15
  168. data/lib/grape/util/strict_hash_configuration.rb +0 -108
  169. data/lib/grape/validations/attributes_doc.rb +0 -60
  170. data/lib/grape/validations/validator_factory.rb +0 -15
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Util
5
+ module Lazy
6
+ # Abstract parent for lazy wrappers used by the remount/configuration
7
+ # machinery. Call sites can type-check with +is_a?(Grape::Util::Lazy::Base)+
8
+ # instead of enumerating the concrete subclasses.
9
+ class Base
10
+ def to_s
11
+ evaluate.to_s
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -3,8 +3,9 @@
3
3
  module Grape
4
4
  module Util
5
5
  module Lazy
6
- class Block
6
+ class Block < Base
7
7
  def initialize(&new_block)
8
+ super()
8
9
  @block = new_block
9
10
  end
10
11
 
@@ -15,14 +16,6 @@ module Grape
15
16
  def evaluate
16
17
  @block.call({})
17
18
  end
18
-
19
- def lazy?
20
- true
21
- end
22
-
23
- def to_s
24
- evaluate.to_s
25
- end
26
19
  end
27
20
  end
28
21
  end
@@ -3,10 +3,11 @@
3
3
  module Grape
4
4
  module Util
5
5
  module Lazy
6
- class Value
6
+ class Value < Base
7
7
  attr_reader :access_keys
8
8
 
9
9
  def initialize(value, access_keys = [])
10
+ super()
10
11
  @value = value
11
12
  @access_keys = access_keys
12
13
  end
@@ -20,18 +21,10 @@ module Grape
20
21
  @value
21
22
  end
22
23
 
23
- def lazy?
24
- true
25
- end
26
-
27
24
  def reached_by(parent_access_keys, access_key)
28
25
  @access_keys = parent_access_keys + [access_key]
29
26
  self
30
27
  end
31
-
32
- def to_s
33
- evaluate.to_s
34
- end
35
28
  end
36
29
  end
37
30
  end
@@ -5,28 +5,25 @@ module Grape
5
5
  module Lazy
6
6
  class ValueEnumerable < Value
7
7
  def [](key)
8
- if @value_hash[key].nil?
9
- Value.new(nil).reached_by(access_keys, key)
10
- else
11
- @value_hash[key].reached_by(access_keys, key)
12
- end
8
+ return Value.new(nil).reached_by(access_keys, key) if @value_hash[key].nil?
9
+
10
+ @value_hash[key].reached_by(access_keys, key)
13
11
  end
14
12
 
15
13
  def fetch(access_keys)
16
- fetched_keys = access_keys.dup
17
- value = self[fetched_keys.shift]
18
- fetched_keys.any? ? value.fetch(fetched_keys) : value
14
+ access_keys.reduce(self) { |node, key| node[key] }
19
15
  end
20
16
 
21
17
  def []=(key, value)
22
- @value_hash[key] = case value
23
- when Hash
24
- ValueHash.new(value)
25
- when Array
26
- ValueArray.new(value)
27
- else
28
- Value.new(value)
29
- end
18
+ value_class = case value
19
+ when Hash
20
+ ValueHash
21
+ when Array
22
+ ValueArray
23
+ else
24
+ Value
25
+ end
26
+ @value_hash[key] = value_class.new(value)
30
27
  end
31
28
  end
32
29
  end
@@ -7,7 +7,11 @@ module Grape
7
7
 
8
8
  # based on the HTTP Accept header with the pattern:
9
9
  # application/vnd.:vendor-:version+:format
10
- VENDOR_VERSION_HEADER_REGEX = /\Avnd\.(?<vendor>[a-z0-9.\-_!^]+?)(?:-(?<version>[a-z0-9*.]+))?(?:\+(?<format>[a-z0-9*\-.]+))?\z/.freeze
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.
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:)
13
17
  @type = type
@@ -20,10 +24,6 @@ module Grape
20
24
  end
21
25
 
22
26
  def ==(other)
23
- eql?(other)
24
- end
25
-
26
- def eql?(other)
27
27
  self.class == other.class &&
28
28
  other.type == type &&
29
29
  other.subtype == subtype &&
@@ -31,6 +31,7 @@ module Grape
31
31
  other.version == version &&
32
32
  other.format == format
33
33
  end
34
+ alias eql? ==
34
35
 
35
36
  def hash
36
37
  [self.class, type, subtype, vendor, version, format].hash
@@ -44,23 +45,26 @@ module Grape
44
45
  def parse(media_type)
45
46
  return if media_type.blank?
46
47
 
47
- type, subtype = media_type.split('/', 2)
48
+ type, subtype = media_type.downcase.split('/', 2)
48
49
  return if type.blank? || subtype.blank?
49
50
 
50
- new(type: type, subtype: subtype)
51
+ new(type:, subtype:)
51
52
  end
52
53
 
53
54
  def match?(media_type)
54
55
  return false if media_type.blank?
55
56
 
56
- subtype = media_type.split('/', 2).last
57
+ subtype = media_type.downcase.split('/', 2).last
57
58
  return false if subtype.blank?
58
59
 
59
60
  VENDOR_VERSION_HEADER_REGEX.match?(subtype)
60
61
  end
61
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.
62
66
  def best_quality_media_type(header, available_media_types)
63
- 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)
64
68
  end
65
69
  end
66
70
 
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Util
5
+ module PathNormalizer
6
+ # Taken from Rails
7
+ # call("/foo") # => "/foo"
8
+ # call("/foo/") # => "/foo"
9
+ # call("foo") # => "/foo"
10
+ # call("") # => "/"
11
+ # call("/%ab") # => "/%AB"
12
+ # https://github.com/rails/rails/blob/00cc4ff0259c0185fe08baadaa40e63ea2534f6e/actionpack/lib/action_dispatch/journey/router/utils.rb#L19
13
+ def self.call(path)
14
+ return '/' unless path
15
+ return path if path == '/'
16
+
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?('//'))
21
+
22
+ # Slow path. The bangs below are safe because +normalized+ is a fresh
23
+ # String built here, never the one the caller passed in.
24
+ encoding = path.encoding
25
+ normalized = "/#{path}"
26
+ normalized.squeeze!('/')
27
+
28
+ unless normalized == '/'
29
+ normalized.delete_suffix!('/')
30
+ normalized.gsub!(/(%[a-f0-9]{2})/) { ::Regexp.last_match(1).upcase }
31
+ end
32
+
33
+ normalized.force_encoding(encoding)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -7,8 +7,8 @@ module Grape
7
7
  short_name = build_short_name(klass)
8
8
  return if short_name.nil?
9
9
 
10
- warn "#{short_name} is already registered with class #{klass}" if registry.key?(short_name)
11
- registry[short_name] = klass
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] = 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 ||= {}.with_indifferent_access
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,25 +2,54 @@
2
2
 
3
3
  module Grape
4
4
  module Util
5
- class StackableValues < BaseInheritable
6
- # Even if there is no value, an empty array will be returned.
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
20
+ EMPTY = [].freeze
21
+
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.
7
35
  def [](name)
8
- inherited_value = inherited_values[name]
9
- new_value = new_values[name]
36
+ inherited_value = @inherited_values[name]
37
+ new_value = @new_values && @new_values[name]
10
38
 
11
- return new_value || [] unless inherited_value
39
+ return new_value || EMPTY unless inherited_value
12
40
 
13
41
  concat_values(inherited_value, new_value)
14
42
  end
15
43
 
16
- def []=(name, value)
17
- new_values[name] ||= []
18
- 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
19
48
  end
20
49
 
21
50
  def to_hash
22
- keys.each_with_object({}) do |key, result|
23
- result[key] = self[key]
51
+ keys.to_h do |key|
52
+ [key, self[key]]
24
53
  end
25
54
  end
26
55
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Util
5
+ module Translation
6
+ FALLBACK_LOCALE = :en
7
+ private_constant :FALLBACK_LOCALE
8
+ # Sentinel returned by I18n when a key is missing (passed as the default:
9
+ # value). Using a named class rather than plain Object.new makes it
10
+ # identifiable in debug output and immune to backends that call .to_s on
11
+ # the default before returning it.
12
+ MISSING = Class.new { def inspect = 'Grape::Util::Translation::MISSING' }.new.freeze
13
+ private_constant :MISSING
14
+
15
+ private
16
+
17
+ # Extra keyword args (**) are forwarded verbatim to I18n as interpolation
18
+ # variables (e.g. +min:+, +max:+ from LengthValidator's Hash message).
19
+ # Callers must not pass unintended keyword arguments — any extra keyword
20
+ # will silently become an I18n interpolation variable.
21
+ def translate(key, default: MISSING, scope: 'grape.errors.messages', locale: nil, **)
22
+ i18n_opts = { default:, scope:, ** }
23
+ i18n_opts[:locale] = locale if locale
24
+ message = ::I18n.translate(key, **i18n_opts)
25
+ return message unless message.equal?(MISSING)
26
+
27
+ effective_default = default.equal?(MISSING) ? [*Array(scope), key].join('.') : default
28
+ return effective_default if fallback_locale?(locale) || fallback_locale_unavailable?
29
+
30
+ ::I18n.translate(key, default: effective_default, scope:, locale: FALLBACK_LOCALE, **)
31
+ end
32
+
33
+ def fallback_locale?(locale)
34
+ (locale || ::I18n.locale) == FALLBACK_LOCALE
35
+ end
36
+
37
+ def fallback_locale_unavailable?
38
+ ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -3,56 +3,88 @@
3
3
  module Grape
4
4
  module Validations
5
5
  class AttributesIterator
6
- include Enumerable
7
-
8
- attr_reader :scope
9
-
10
- def initialize(validator, scope, params)
6
+ # +attrs+ and +scope+ are static per validator; only +params+ varies
7
+ # per request, so an instance can be built once and reused (it keeps
8
+ # no request-derived state). Reused instances are shared across
9
+ # threads, so +each+ must stay free of mutable instance state.
10
+ def initialize(attrs, scope)
11
+ @attrs = attrs
11
12
  @scope = scope
12
- @attrs = validator.attrs
13
- @original_params = scope.params(params)
14
- @params = Array.wrap(@original_params)
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
15
18
  end
16
19
 
17
- def each(&block)
18
- do_each(@params, &block) # because we need recursion for nested arrays
20
+ def each(params, &)
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
+
29
+ # because we need recursion for nested arrays
30
+ do_each(Array.wrap(original_params), original_params, &)
19
31
  end
20
32
 
21
33
  private
22
34
 
23
- def do_each(params_to_process, parent_indicies = [], &block)
24
- @scope.reset_index # gets updated depending on the size of params_to_process
35
+ def do_each(params_to_process, original_params, parent_indices = [], &block)
25
36
  params_to_process.each_with_index do |resource_params, index|
26
37
  # when we get arrays of arrays it means that target element located inside array
27
- # we need this because we want to know parent arrays indicies
28
- if resource_params.is_a?(Array)
29
- do_each(resource_params, [index] + parent_indicies, &block)
38
+ # we need this because we want to know parent arrays indices
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
45
+ do_each(resource_params, original_params, [index] + parent_indices, &block)
30
46
  next
31
47
  end
32
48
 
33
- if @scope.type == Array
34
- next unless @original_params.is_a?(Array) # do not validate content of array if it isn't array
49
+ if @scope.iterates_elements?
50
+ next unless original_params.is_a?(Array) # do not validate content of array if it isn't array
35
51
 
36
- # fill current and parent scopes with correct array indicies
37
- parent_scope = @scope.parent
38
- parent_indicies.each do |parent_index|
39
- parent_scope.index = parent_index
40
- parent_scope = parent_scope.parent
41
- end
42
- @scope.index = index
52
+ store_indices(@scope, index, parent_indices)
53
+ elsif original_params.is_a?(Array)
54
+ # Lateral scope (no @element) whose params resolved to an array —
55
+ # delegate index tracking to the nearest element-iterating ancestor
56
+ # so that full_name produces the correct bracketed index.
57
+ target = @scope.nearest_array_ancestor
58
+ store_indices(target, index, parent_indices) if target
43
59
  end
44
60
 
45
- yield_attributes(resource_params, @attrs, &block)
61
+ yield_attributes(resource_params, &block)
62
+ end
63
+ end
64
+
65
+ def store_indices(target_scope, index, parent_indices)
66
+ # No tracker means we're outside a ParamScopeTracker.track block (e.g.
67
+ # a unit test that invokes a validator directly). Index tracking is
68
+ # skipped — full_name will produce bracket-less names — but validation
69
+ # continues rather than crashing.
70
+ tracker = ParamScopeTracker.current or return
71
+ parent_scope = target_scope.parent
72
+ parent_indices.each do |parent_index|
73
+ break unless parent_scope
74
+
75
+ tracker.store_index(parent_scope, parent_index)
76
+ parent_scope = parent_scope.parent
46
77
  end
78
+ tracker.store_index(target_scope, index)
47
79
  end
48
80
 
49
- def yield_attributes(_resource_params, _attrs)
81
+ def yield_attributes(_resource_params)
50
82
  raise NotImplementedError
51
83
  end
52
84
 
53
- # This is a special case so that we can ignore tree's where option
54
- # values are missing lower down. Unfortunately we can remove this
55
- # are the parameter parsing stage as they are required to ensure
85
+ # This is a special case so that we can ignore trees where option
86
+ # values are missing lower down. Unfortunately we can't remove this
87
+ # at the parameter parsing stage as they are required to ensure
56
88
  # the correct indexing is maintained
57
89
  def skip?(val)
58
90
  val == Grape::DSL::Parameters::EmptyOptionalValue
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Validations
5
+ # Immutable value object describing how a parameter is coerced. Assembled
6
+ # by {ValidationsSpec#coerce_options} from the parsed +type+/+coerce_with+/
7
+ # +coerce_message+ declaration — never written by the user — and consumed
8
+ # by {ParamsScope#check_coerce_with} / {ParamsScope#validate_coerce} and by
9
+ # {Validators::Validators::CoerceValidator} (which receives it as its
10
+ # +options+ argument).
11
+ #
12
+ # All three fields may be +nil+ (e.g. a remountable API evaluated on its
13
+ # base instance has no resolved +type+ yet).
14
+ # +coerce_method+ (not +method+) avoids shadowing +Object#method+.
15
+ CoerceOptions = Data.define(:type, :coerce_method, :message) do
16
+ def initialize(type: nil, coerce_method: nil, message: nil)
17
+ super
18
+ end
19
+ end
20
+ end
21
+ end
@@ -9,25 +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.namespace_stackable(:contract_key_map, key_map)
24
-
25
- validator_options = {
26
- validator_class: Grape::Validations.require_validator(:contract_scope),
27
- opts: { schema: contract, fail_fast: false }
28
- }
29
-
30
- api.namespace_stackable(:validations, validator_options)
24
+ api.inheritable_setting.add_contract_key_map(key_map)
25
+ api.inheritable_setting.add_validation(Validators::ContractScopeValidator.new(schema:))
31
26
  end
32
27
  end
33
28
  end
@@ -5,7 +5,7 @@ module Grape
5
5
  class MultipleAttributesIterator < AttributesIterator
6
6
  private
7
7
 
8
- def yield_attributes(resource_params, _attrs)
8
+ def yield_attributes(resource_params)
9
9
  yield resource_params unless skip?(resource_params)
10
10
  end
11
11
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Validations
5
+ # Stand-in for the +@api+ object that {ParamsScope} normally writes to.
6
+ # Used when evaluating a single variant block of a +oneof:+ schema so that
7
+ # the variant's validators are captured locally rather than registered on
8
+ # the real API. Exposes only the slice of the API surface that
9
+ # ParamsScope and its helpers touch during definition.
10
+ class OneofCollector
11
+ extend Forwardable
12
+
13
+ attr_reader :inheritable_setting
14
+
15
+ def_delegator :@inheritable_setting, :validations
16
+
17
+ def initialize
18
+ @inheritable_setting = Grape::Util::InheritableSetting.new
19
+ @inheritable_setting.do_not_document!
20
+ end
21
+
22
+ def configuration
23
+ nil
24
+ end
25
+
26
+ # Evaluate +variant_block+ in a fresh +ParamsScope+ backed by a new
27
+ # collector and return the validators that the block registered.
28
+ def self.collect(variant_block)
29
+ collector = new
30
+ ParamsScope.new(api: collector, type: Hash, &variant_block)
31
+ collector.validations
32
+ end
33
+ end
34
+ end
35
+ end