grape 3.1.0 → 3.2.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +76 -161
  4. data/UPGRADING.md +106 -0
  5. data/grape.gemspec +2 -2
  6. data/lib/grape/api/instance.rb +1 -1
  7. data/lib/grape/api.rb +1 -1
  8. data/lib/grape/declared_params_handler.rb +3 -3
  9. data/lib/grape/dsl/declared.rb +1 -1
  10. data/lib/grape/dsl/desc.rb +1 -1
  11. data/lib/grape/dsl/inside_route.rb +9 -9
  12. data/lib/grape/dsl/parameters.rb +14 -14
  13. data/lib/grape/dsl/routing.rb +8 -8
  14. data/lib/grape/endpoint.rb +45 -50
  15. data/lib/grape/error_formatter/base.rb +2 -2
  16. data/lib/grape/exceptions/base.rb +18 -44
  17. data/lib/grape/exceptions/incompatible_option_values.rb +1 -1
  18. data/lib/grape/exceptions/invalid_accept_header.rb +1 -1
  19. data/lib/grape/exceptions/invalid_formatter.rb +1 -1
  20. data/lib/grape/exceptions/invalid_message_body.rb +1 -1
  21. data/lib/grape/exceptions/invalid_version_header.rb +1 -1
  22. data/lib/grape/exceptions/invalid_versioner_option.rb +1 -1
  23. data/lib/grape/exceptions/method_not_allowed.rb +1 -1
  24. data/lib/grape/exceptions/missing_mime_type.rb +1 -1
  25. data/lib/grape/exceptions/request_error.rb +11 -0
  26. data/lib/grape/exceptions/unknown_auth_strategy.rb +1 -1
  27. data/lib/grape/exceptions/unknown_parameter.rb +1 -1
  28. data/lib/grape/exceptions/unknown_params_builder.rb +1 -1
  29. data/lib/grape/exceptions/unknown_validator.rb +1 -1
  30. data/lib/grape/exceptions/validation.rb +7 -4
  31. data/lib/grape/exceptions/validation_errors.rb +13 -7
  32. data/lib/grape/locale/en.yml +0 -5
  33. data/lib/grape/middleware/auth/base.rb +2 -0
  34. data/lib/grape/middleware/base.rb +2 -4
  35. data/lib/grape/middleware/error.rb +2 -2
  36. data/lib/grape/middleware/formatter.rb +1 -1
  37. data/lib/grape/middleware/versioner/accept_version_header.rb +1 -1
  38. data/lib/grape/request.rb +2 -10
  39. data/lib/grape/router/pattern.rb +1 -1
  40. data/lib/grape/router.rb +4 -2
  41. data/lib/grape/util/api_description.rb +1 -1
  42. data/lib/grape/util/deep_freeze.rb +35 -0
  43. data/lib/grape/util/inheritable_setting.rb +1 -1
  44. data/lib/grape/util/media_type.rb +1 -1
  45. data/lib/grape/util/translation.rb +42 -0
  46. data/lib/grape/validations/attributes_iterator.rb +33 -18
  47. data/lib/grape/validations/contract_scope.rb +1 -7
  48. data/lib/grape/validations/multiple_attributes_iterator.rb +1 -1
  49. data/lib/grape/validations/param_scope_tracker.rb +57 -0
  50. data/lib/grape/validations/params_scope.rb +111 -107
  51. data/lib/grape/validations/single_attribute_iterator.rb +2 -2
  52. data/lib/grape/validations/validators/all_or_none_of_validator.rb +6 -3
  53. data/lib/grape/validations/validators/allow_blank_validator.rb +10 -5
  54. data/lib/grape/validations/validators/at_least_one_of_validator.rb +5 -2
  55. data/lib/grape/validations/validators/base.rb +95 -18
  56. data/lib/grape/validations/validators/coerce_validator.rb +15 -35
  57. data/lib/grape/validations/validators/contract_scope_validator.rb +10 -8
  58. data/lib/grape/validations/validators/default_validator.rb +12 -18
  59. data/lib/grape/validations/validators/exactly_one_of_validator.rb +10 -3
  60. data/lib/grape/validations/validators/except_values_validator.rb +13 -4
  61. data/lib/grape/validations/validators/length_validator.rb +21 -22
  62. data/lib/grape/validations/validators/multiple_params_base.rb +5 -5
  63. data/lib/grape/validations/validators/mutually_exclusive_validator.rb +3 -1
  64. data/lib/grape/validations/validators/presence_validator.rb +4 -2
  65. data/lib/grape/validations/validators/regexp_validator.rb +8 -10
  66. data/lib/grape/validations/validators/same_as_validator.rb +6 -15
  67. data/lib/grape/validations/validators/values_validator.rb +29 -21
  68. data/lib/grape/version.rb +1 -1
  69. data/lib/grape.rb +18 -1
  70. metadata +12 -14
  71. data/lib/grape/exceptions/conflicting_types.rb +0 -11
  72. data/lib/grape/exceptions/empty_message_body.rb +0 -11
  73. data/lib/grape/exceptions/invalid_parameters.rb +0 -11
  74. data/lib/grape/exceptions/too_deep_parameters.rb +0 -11
  75. data/lib/grape/exceptions/too_many_multipart_files.rb +0 -11
  76. data/lib/grape/validations/validator_factory.rb +0 -15
@@ -38,7 +38,7 @@ module Grape
38
38
  else
39
39
  # Allow content-type to be explicitly overwritten
40
40
  formatter = fetch_formatter(headers, options)
41
- bodymap = ActiveSupport::Notifications.instrument('format_response.grape', formatter: formatter, env: env) do
41
+ bodymap = ActiveSupport::Notifications.instrument('format_response.grape', formatter:, env:) do
42
42
  bodies.collect { |body| formatter.call(body, env) }
43
43
  end
44
44
  Rack::Response.new(bodymap, status, headers)
@@ -30,7 +30,7 @@ module Grape
30
30
  private
31
31
 
32
32
  def not_acceptable!(message)
33
- throw :error, status: 406, headers: error_headers, message: message
33
+ throw :error, status: 406, headers: error_headers, message:
34
34
  end
35
35
  end
36
36
  end
data/lib/grape/request.rb CHANGED
@@ -166,16 +166,8 @@ module Grape
166
166
 
167
167
  def make_params
168
168
  @params_builder.call(rack_params).deep_merge!(grape_routing_args)
169
- rescue EOFError
170
- raise Grape::Exceptions::EmptyMessageBody.new(content_type)
171
- rescue Rack::Multipart::MultipartPartLimitError, Rack::Multipart::MultipartTotalPartLimitError
172
- raise Grape::Exceptions::TooManyMultipartFiles.new(Rack::Utils.multipart_part_limit)
173
- rescue Rack::QueryParser::ParamsTooDeepError
174
- raise Grape::Exceptions::TooDeepParameters.new(Rack::Utils.param_depth_limit)
175
- rescue Rack::Utils::ParameterTypeError
176
- raise Grape::Exceptions::ConflictingTypes
177
- rescue Rack::Utils::InvalidParameterError
178
- raise Grape::Exceptions::InvalidParameters
169
+ rescue *Grape::RACK_ERRORS
170
+ raise Grape::Exceptions::RequestError
179
171
  end
180
172
 
181
173
  def build_headers
@@ -16,7 +16,7 @@ module Grape
16
16
  def initialize(origin:, suffix:, anchor:, params:, format:, version:, requirements:)
17
17
  @origin = origin
18
18
  @path = PatternCache[[build_path_from_pattern(@origin, anchor), suffix]]
19
- @pattern = Mustermann::Grape.new(@path, uri_decode: true, params: params, capture: extract_capture(format, version, requirements))
19
+ @pattern = Mustermann::Grape.new(@path, uri_decode: true, params:, capture: extract_capture(format, version, requirements))
20
20
  @to_regexp = @pattern.to_regexp
21
21
  end
22
22
 
data/lib/grape/router.rb CHANGED
@@ -76,6 +76,9 @@ module Grape
76
76
  any.endpoint
77
77
  end
78
78
 
79
+ DEFAULT_RESPONSE_HEADERS = Grape::Util::Header.new.merge('X-Cascade' => 'pass').freeze
80
+ DEFAULT_RESPONSE_BODY = ['404 Not Found'].freeze
81
+
79
82
  private
80
83
 
81
84
  def identity(input, method, env)
@@ -146,8 +149,7 @@ module Grape
146
149
  end
147
150
 
148
151
  def default_response
149
- headers = Grape::Util::Header.new.merge('X-Cascade' => 'pass')
150
- [404, headers, ['404 Not Found']]
152
+ [404, DEFAULT_RESPONSE_HEADERS.dup, DEFAULT_RESPONSE_BODY.dup]
151
153
  end
152
154
 
153
155
  def match?(input, method)
@@ -25,7 +25,7 @@ module Grape
25
25
 
26
26
  def initialize(description, endpoint_configuration, &)
27
27
  @endpoint_configuration = endpoint_configuration
28
- @attributes = { description: description }
28
+ @attributes = { description: }
29
29
  instance_eval(&)
30
30
  end
31
31
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Util
5
+ module DeepFreeze
6
+ module_function
7
+
8
+ # Recursively freezes Hash (keys and values), Array (elements), and String
9
+ # objects. All other types are returned as-is.
10
+ #
11
+ # Intentionally left unfrozen:
12
+ # - Procs / lambdas — may be deferred DB-backed callables
13
+ # - Coercers (e.g. ArrayCoercer) — use lazy ivar memoization at request time
14
+ # - Classes / Modules — shared constants that must remain open
15
+ # - ParamsScope — self-freezes at the end of its own initialize
16
+ def deep_freeze(obj)
17
+ case obj
18
+ when Hash
19
+ obj.each do |k, v|
20
+ deep_freeze(k)
21
+ deep_freeze(v)
22
+ end
23
+ obj.freeze
24
+ when Array
25
+ obj.each { |v| deep_freeze(v) }
26
+ obj.freeze
27
+ when String
28
+ obj.freeze
29
+ else
30
+ obj
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -55,7 +55,7 @@ module Grape
55
55
  namespace_reverse_stackable.inherited_values = parent.namespace_reverse_stackable
56
56
  self.route = parent.route.merge(route)
57
57
 
58
- point_in_time_copies.map { |cloned_one| cloned_one.inherit_from parent }
58
+ point_in_time_copies.each { |cloned_one| cloned_one.inherit_from parent }
59
59
  end
60
60
 
61
61
  # Create a point-in-time copy of this settings instance, with clones of
@@ -47,7 +47,7 @@ module Grape
47
47
  type, subtype = media_type.split('/', 2)
48
48
  return if type.blank? || subtype.blank?
49
49
 
50
- new(type: type, subtype: subtype)
50
+ new(type:, subtype:)
51
51
  end
52
52
 
53
53
  def match?(media_type)
@@ -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
@@ -7,9 +7,9 @@ module Grape
7
7
 
8
8
  attr_reader :scope
9
9
 
10
- def initialize(validator, scope, params)
10
+ def initialize(attrs, scope, params)
11
+ @attrs = attrs
11
12
  @scope = scope
12
- @attrs = validator.attrs
13
13
  @original_params = scope.params(params)
14
14
  @params = Array.wrap(@original_params)
15
15
  end
@@ -20,39 +20,54 @@ module Grape
20
20
 
21
21
  private
22
22
 
23
- def do_each(params_to_process, parent_indicies = [], &block)
24
- @scope.reset_index # gets updated depending on the size of params_to_process
23
+ def do_each(params_to_process, parent_indices = [], &block)
25
24
  params_to_process.each_with_index do |resource_params, index|
26
25
  # 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
26
+ # we need this because we want to know parent arrays indices
28
27
  if resource_params.is_a?(Array)
29
- do_each(resource_params, [index] + parent_indicies, &block)
28
+ do_each(resource_params, [index] + parent_indices, &block)
30
29
  next
31
30
  end
32
31
 
33
32
  if @scope.type == Array
34
33
  next unless @original_params.is_a?(Array) # do not validate content of array if it isn't array
35
34
 
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
35
+ store_indices(@scope, index, parent_indices)
36
+ elsif @original_params.is_a?(Array)
37
+ # 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.
40
+ target = @scope.nearest_array_ancestor
41
+ store_indices(target, index, parent_indices) if target
43
42
  end
44
43
 
45
- yield_attributes(resource_params, @attrs, &block)
44
+ yield_attributes(resource_params, &block)
46
45
  end
47
46
  end
48
47
 
49
- def yield_attributes(_resource_params, _attrs)
48
+ def store_indices(target_scope, index, parent_indices)
49
+ # No tracker means we're outside a ParamScopeTracker.track block (e.g.
50
+ # a unit test that invokes a validator directly). Index tracking is
51
+ # skipped — full_name will produce bracket-less names — but validation
52
+ # continues rather than crashing.
53
+ tracker = ParamScopeTracker.current or return
54
+ parent_scope = target_scope.parent
55
+ parent_indices.each do |parent_index|
56
+ break unless parent_scope
57
+
58
+ tracker.store_index(parent_scope, parent_index)
59
+ parent_scope = parent_scope.parent
60
+ end
61
+ tracker.store_index(target_scope, index)
62
+ end
63
+
64
+ def yield_attributes(_resource_params)
50
65
  raise NotImplementedError
51
66
  end
52
67
 
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
68
+ # This is a special case so that we can ignore trees where option
69
+ # values are missing lower down. Unfortunately we can't remove this
70
+ # at the parameter parsing stage as they are required to ensure
56
71
  # the correct indexing is maintained
57
72
  def skip?(val)
58
73
  val == Grape::DSL::Parameters::EmptyOptionalValue
@@ -21,13 +21,7 @@ module Grape
21
21
  end
22
22
 
23
23
  api.inheritable_setting.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.inheritable_setting.namespace_stackable[:validations] = validator_options
24
+ api.inheritable_setting.namespace_stackable[:validations] = Validators::ContractScopeValidator.new(schema: contract)
31
25
  end
32
26
  end
33
27
  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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Validations
5
+ # Holds per-request mutable state that must not live on shared ParamsScope
6
+ # instances. Both trackers are identity-keyed hashes so that ParamsScope
7
+ # objects can serve as keys without relying on value equality.
8
+ #
9
+ # Lifecycle is managed by Endpoint#run_validators via +.track+.
10
+ # Use +.current+ to access the instance for the running request.
11
+ class ParamScopeTracker
12
+ # Fiber-local key used to store the current tracker.
13
+ # Fiber[] (Ruby 3.0+) is used instead of Thread.current[] so that
14
+ # fiber-based servers (e.g. Falcon with async) isolate each request's
15
+ # tracker within its own fiber rather than sharing state across all
16
+ # fibers running on the same thread.
17
+ FIBER_KEY = :grape_param_scope_tracker
18
+ EMPTY_PARAMS = [].freeze
19
+
20
+ def self.track
21
+ previous = Fiber[FIBER_KEY]
22
+ Fiber[FIBER_KEY] = new
23
+ yield
24
+ ensure
25
+ Fiber[FIBER_KEY] = previous
26
+ end
27
+
28
+ def self.current
29
+ Fiber[FIBER_KEY]
30
+ end
31
+
32
+ def initialize
33
+ @index_tracker = {}.compare_by_identity
34
+ @qualifying_params_tracker = {}.compare_by_identity
35
+ end
36
+
37
+ def store_index(scope, index)
38
+ @index_tracker.store(scope, index)
39
+ end
40
+
41
+ def index_for(scope)
42
+ @index_tracker[scope]
43
+ end
44
+
45
+ # Returns qualifying params for +scope+, or EMPTY_PARAMS if none were stored.
46
+ # Note: an explicitly stored empty array and "never stored" are treated identically
47
+ # by callers (both yield a blank result that falls through to the parent params).
48
+ def qualifying_params(scope)
49
+ @qualifying_params_tracker.fetch(scope, EMPTY_PARAMS)
50
+ end
51
+
52
+ def store_qualifying_params(scope, params)
53
+ @qualifying_params_tracker.store(scope, params)
54
+ end
55
+ end
56
+ end
57
+ end