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
@@ -3,8 +3,11 @@
3
3
  module Grape
4
4
  module Validations
5
5
  class ParamsScope
6
- attr_accessor :element, :parent, :index
7
- attr_reader :type, :params_meeting_dependency
6
+ attr_reader :parent, :type, :nearest_array_ancestor, :full_path
7
+
8
+ def qualifying_params
9
+ ParamScopeTracker.current&.qualifying_params(self)
10
+ end
8
11
 
9
12
  include Grape::DSL::Parameters
10
13
  include Grape::Validations::ParamsDocumentation
@@ -17,7 +20,7 @@ module Grape
17
20
  SPECIAL_JSON = [JSON, Array[JSON]].freeze
18
21
 
19
22
  class Attr
20
- attr_accessor :key, :scope
23
+ attr_reader :key, :scope
21
24
 
22
25
  # Open up a new ParamsScope::Attr
23
26
  # @param key [Hash, Symbol] key of attr
@@ -47,36 +50,39 @@ module Grape
47
50
 
48
51
  # Open up a new ParamsScope, allowing parameter definitions per
49
52
  # Grape::DSL::Params.
50
- # @param opts [Hash] options for this scope
51
- # @option opts :element [Symbol] the element that contains this scope; for
52
- # this to be relevant, @parent must be set
53
- # @option opts :element_renamed [Symbol, nil] whenever this scope should
53
+ # @param api [API] the API endpoint to modify
54
+ # @param element [Symbol] the element that contains this scope; for
55
+ # this to be relevant, parent must be set
56
+ # @param element_renamed [Symbol, nil] whenever this scope should
54
57
  # be renamed and to what, given +nil+ no renaming is done
55
- # @option opts :parent [ParamsScope] the scope containing this scope
56
- # @option opts :api [API] the API endpoint to modify
57
- # @option opts :optional [Boolean] whether or not this scope needs to have
58
+ # @param parent [ParamsScope] the scope containing this scope
59
+ # @param optional [Boolean] whether or not this scope needs to have
58
60
  # any parameters set or not
59
- # @option opts :type [Class] a type meant to govern this scope (deprecated)
60
- # @option opts :type [Hash] group options for this scope
61
- # @option opts :dependent_on [Symbol] if present, this scope should only
61
+ # @param type [Class] a type meant to govern this scope (deprecated)
62
+ # @param type [Hash] group options for this scope
63
+ # @param dependent_on [Symbol] if present, this scope should only
62
64
  # validate if this param is present in the parent scope
63
65
  # @yield the instance context, open for parameter definitions
64
- def initialize(opts, &block)
65
- @element = opts[:element]
66
- @element_renamed = opts[:element_renamed]
67
- @parent = opts[:parent]
68
- @api = opts[:api]
69
- @optional = opts[:optional] || false
70
- @type = opts[:type]
71
- @group = opts[:group]
72
- @dependent_on = opts[:dependent_on]
73
- @params_meeting_dependency = []
66
+ def initialize(api:, element: nil, element_renamed: nil, parent: nil, optional: false, type: nil, group: nil, dependent_on: nil, &block)
67
+ @element = element
68
+ @element_renamed = element_renamed
69
+ @parent = parent
70
+ @api = api
71
+ @optional = optional
72
+ @type = type
73
+ @group = group
74
+ @dependent_on = dependent_on
75
+ # Must be an ivar: push_declared_params is dispatched on self during
76
+ # instance_eval, so local variables from initialize are unreachable.
77
+ # configure_declared_params consumes it and clears @declared_params to nil.
74
78
  @declared_params = []
75
- @index = nil
79
+ @full_path = build_full_path
76
80
 
77
81
  instance_eval(&block) if block
78
82
 
79
83
  configure_declared_params
84
+ @nearest_array_ancestor = find_nearest_array_ancestor
85
+ freeze
80
86
  end
81
87
 
82
88
  def configuration
@@ -90,9 +96,9 @@ module Grape
90
96
 
91
97
  return false if @optional && (scoped_params.blank? || all_element_blank?(scoped_params))
92
98
  return false unless meets_dependency?(scoped_params, parameters)
93
- return true if parent.nil?
99
+ return true if @parent.nil?
94
100
 
95
- parent.should_validate?(parameters)
101
+ @parent.should_validate?(parameters)
96
102
  end
97
103
 
98
104
  def meets_dependency?(params, request_params)
@@ -100,8 +106,9 @@ module Grape
100
106
  return false if @parent.present? && !@parent.meets_dependency?(@parent.params(request_params), request_params)
101
107
 
102
108
  if params.is_a?(Array)
103
- @params_meeting_dependency = params.flatten.filter { |param| meets_dependency?(param, request_params) }
104
- return @params_meeting_dependency.present?
109
+ filtered = params.flatten.filter { |param| meets_dependency?(param, request_params) }
110
+ ParamScopeTracker.current&.store_qualifying_params(self, filtered)
111
+ return filtered.present?
105
112
  end
106
113
 
107
114
  meets_hash_dependency?(params)
@@ -118,28 +125,27 @@ module Grape
118
125
  # params might be anything what looks like a hash, so it must implement a `key?` method
119
126
  return false unless params.respond_to?(:key?)
120
127
 
121
- @dependent_on.each do |dependency|
128
+ @dependent_on.all? do |dependency|
122
129
  if dependency.is_a?(Hash)
123
- dependency_key = dependency.keys[0]
124
- proc = dependency.values[0]
125
- return false unless proc.call(params[dependency_key])
126
- elsif params[dependency].blank?
127
- return false
130
+ key, callable = dependency.first
131
+ callable.call(params[key])
132
+ else
133
+ params[dependency].present?
128
134
  end
129
135
  end
130
-
131
- true
132
136
  end
133
137
 
134
138
  # @return [String] the proper attribute name, with nesting considered.
135
139
  def full_name(name, index: nil)
140
+ tracker = ParamScopeTracker.current
136
141
  if nested?
137
142
  # Find our containing element's name, and append ours.
138
- "#{@parent.full_name(@element)}#{brackets(index || @index)}#{brackets(name)}"
143
+ resolved_index = index || tracker&.index_for(self)
144
+ "#{@parent.full_name(@element)}#{brackets(resolved_index)}#{brackets(name)}"
139
145
  elsif lateral?
140
146
  # Find the name of the element as if it was at the same nesting level
141
147
  # as our parent. We need to forward our index upward to achieve this.
142
- @parent.full_name(name, index: @index)
148
+ @parent.full_name(name, index: tracker&.index_for(self))
143
149
  else
144
150
  # We must be the root scope, so no prefix needed.
145
151
  name.to_s
@@ -174,28 +180,23 @@ module Grape
174
180
  !@optional
175
181
  end
176
182
 
177
- def reset_index
178
- @index = nil
179
- end
180
-
181
183
  protected
182
184
 
183
185
  # Adds a parameter declaration to our list of validations.
184
186
  # @param attrs [Array] (see Grape::DSL::Parameters#requires)
185
- def push_declared_params(attrs, opts = {})
187
+ def push_declared_params(attrs, **opts)
186
188
  opts[:declared_params_scope] = self unless opts.key?(:declared_params_scope)
187
- return @parent.push_declared_params(attrs, opts) if lateral?
189
+ return @parent.push_declared_params(attrs, **opts) if lateral?
188
190
 
189
191
  push_renamed_param(full_path + [attrs.first], opts[:as]) if opts[:as]
190
192
  @declared_params.concat(attrs.map { |attr| ::Grape::Validations::ParamsScope::Attr.new(attr, opts[:declared_params_scope]) })
191
193
  end
192
194
 
193
- # Get the full path of the parameter scope in the hierarchy.
194
- #
195
- # @return [Array<Symbol>] the nesting/path of the current parameter scope
196
- def full_path
195
+ private
196
+
197
+ def build_full_path
197
198
  if nested?
198
- (@parent.full_path + [@element])
199
+ @parent.full_path + [@element]
199
200
  elsif lateral?
200
201
  @parent.full_path
201
202
  else
@@ -203,8 +204,6 @@ module Grape
203
204
  end
204
205
  end
205
206
 
206
- private
207
-
208
207
  # Add a new parameter which should be renamed when using the +#declared+
209
208
  # method.
210
209
  #
@@ -219,9 +218,9 @@ module Grape
219
218
  api_route_setting[:renamed_params] = base
220
219
  end
221
220
 
222
- def require_required_and_optional_fields(context, opts)
223
- except_fields = Array.wrap(opts[:except])
224
- using_fields = opts[:using].keys.delete_if { |f| except_fields.include?(f) }
221
+ def require_required_and_optional_fields(context, using:, except: nil)
222
+ except_fields = Array.wrap(except)
223
+ using_fields = using.keys.delete_if { |f| except_fields.include?(f) }
225
224
 
226
225
  if context == :all
227
226
  optional_fields = except_fields
@@ -231,56 +230,55 @@ module Grape
231
230
  optional_fields = using_fields
232
231
  end
233
232
  required_fields.each do |field|
234
- field_opts = opts[:using][field]
233
+ field_opts = using[field]
235
234
  raise ArgumentError, "required field not exist: #{field}" unless field_opts
236
235
 
237
236
  requires(field, **field_opts)
238
237
  end
239
238
  optional_fields.each do |field|
240
- field_opts = opts[:using][field]
239
+ field_opts = using[field]
241
240
  optional(field, **field_opts) if field_opts
242
241
  end
243
242
  end
244
243
 
245
- def require_optional_fields(context, opts)
246
- optional_fields = opts[:using].keys
244
+ def require_optional_fields(context, using:, except: nil)
245
+ optional_fields = using.keys
247
246
  unless context == :all
248
- except_fields = Array.wrap(opts[:except])
247
+ except_fields = Array.wrap(except)
249
248
  optional_fields.delete_if { |f| except_fields.include?(f) }
250
249
  end
251
250
  optional_fields.each do |field|
252
- field_opts = opts[:using][field]
251
+ field_opts = using[field]
253
252
  optional(field, **field_opts) if field_opts
254
253
  end
255
254
  end
256
255
 
257
- def validate_attributes(attrs, opts, &block)
258
- validations = opts.clone
259
- validations[:type] ||= Array if block
260
- validates(attrs, validations)
256
+ def validate_attributes(attrs, **opts, &block)
257
+ opts[:type] ||= Array if block
258
+ validates(attrs, opts)
261
259
  end
262
260
 
263
261
  # Returns a new parameter scope, subordinate to the current one and nested
264
- # under the parameter corresponding to `attrs.first`.
265
- # @param attrs [Array] the attributes passed to the `requires` or
266
- # `optional` invocation that opened this scope.
267
- # @param optional [Boolean] whether the parameter this are nested under
262
+ # under the given element.
263
+ # @param element [Symbol] the parameter name under which this scope is nested
264
+ # @param type [Class] the type governing this scope
265
+ # @param as [Symbol, nil] optional renamed name for the element
266
+ # @param optional [Boolean] whether the parameter this scope is nested under
268
267
  # is optional or not (and hence, whether this block's params will be).
269
268
  # @yield parameter scope
270
- def new_scope(attrs, opts, optional = false, &)
269
+ def new_scope(element, type:, as:, optional: false, &)
271
270
  # if required params are grouped and no type or unsupported type is provided, raise an error
272
- type = opts[:type]
273
- if attrs.first && !optional
271
+ if element && !optional
274
272
  raise Grape::Exceptions::MissingGroupType if type.nil?
275
273
  raise Grape::Exceptions::UnsupportedGroupType unless Grape::Validations::Types.group?(type)
276
274
  end
277
275
 
278
276
  self.class.new(
279
277
  api: @api,
280
- element: attrs.first,
281
- element_renamed: opts[:as],
278
+ element:,
279
+ element_renamed: as,
282
280
  parent: self,
283
- optional: optional,
281
+ optional:,
284
282
  type: type || Array,
285
283
  group: @group,
286
284
  &
@@ -289,46 +287,50 @@ module Grape
289
287
 
290
288
  # Returns a new parameter scope, not nested under any current-level param
291
289
  # but instead at the same level as the current scope.
292
- # @param options [Hash] options to control how this new scope behaves
293
- # @option options :dependent_on [Symbol] if given, specifies that this
294
- # scope should only validate if this parameter from the above scope is
295
- # present
290
+ # @param dependent_on [Symbol] if given, specifies that this scope should
291
+ # only validate if this parameter from the above scope is present
296
292
  # @yield parameter scope
297
- def new_lateral_scope(options, &)
293
+ def new_lateral_scope(dependent_on:, &)
298
294
  self.class.new(
299
295
  api: @api,
300
- element: nil,
301
296
  parent: self,
302
- options: @optional,
297
+ optional: @optional,
303
298
  type: type == Array ? Array : Hash,
304
- dependent_on: options[:dependent_on],
299
+ dependent_on:,
305
300
  &
306
301
  )
307
302
  end
308
303
 
309
- # Returns a new parameter scope, subordinate to the current one and nested
310
- # under the parameter corresponding to `attrs.first`.
311
- # @param attrs [Array] the attributes passed to the `requires` or
312
- # `optional` invocation that opened this scope.
304
+ # Returns a new parameter scope, subordinate to the current one, sharing
305
+ # the given group options with all parameters defined within.
306
+ # @param group [Hash] common options to merge into each parameter in the scope
313
307
  # @yield parameter scope
314
- def new_group_scope(attrs, &)
315
- self.class.new(api: @api, parent: self, group: attrs.first, &)
308
+ def new_group_scope(group, &)
309
+ self.class.new(api: @api, parent: self, group:, &)
316
310
  end
317
311
 
318
- # Pushes declared params to parent or settings
312
+ # Pushes declared params to parent or settings, then clears @declared_params.
313
+ # Clearing here (rather than in initialize) keeps the lifecycle ownership in
314
+ # one place: this method both consumes and invalidates the ivar so that
315
+ # push_declared_params cannot be called on the frozen scope later.
319
316
  def configure_declared_params
320
317
  push_renamed_param(full_path, @element_renamed) if @element_renamed
321
318
 
322
319
  if nested?
323
- @parent.push_declared_params [element => @declared_params]
320
+ @parent.push_declared_params [@element => @declared_params]
324
321
  else
325
322
  @api.inheritable_setting.namespace_stackable[:declared_params] = @declared_params
326
323
  end
327
-
328
- # params were stored in settings, it can be cleaned from the params scope
324
+ ensure
329
325
  @declared_params = nil
330
326
  end
331
327
 
328
+ def find_nearest_array_ancestor
329
+ scope = @parent
330
+ scope = scope.parent while scope && scope.type != Array
331
+ scope
332
+ end
333
+
332
334
  def validates(attrs, validations)
333
335
  coerce_type = infer_coercion(validations)
334
336
  required = validations.key?(:presence)
@@ -349,7 +351,7 @@ module Grape
349
351
 
350
352
  document_params attrs, validations, coerce_type, values, except_values
351
353
 
352
- opts = derive_validator_options(validations)
354
+ opts = derive_validator_options(validations).freeze
353
355
 
354
356
  # Validate for presence before any other validators
355
357
  validates_presence(validations, attrs, opts)
@@ -357,7 +359,7 @@ module Grape
357
359
  # Before we run the rest of the validators, let's handle
358
360
  # whatever coercion so that we are working with correctly
359
361
  # type casted values
360
- coerce_type validations, attrs, required, opts
362
+ coerce_type validations.extract!(:coerce, :coerce_with, :coerce_message), attrs, required, opts
361
363
 
362
364
  validations.each do |type, options|
363
365
  # Don't try to look up validators for documentation params that don't have one.
@@ -430,7 +432,12 @@ module Grape
430
432
  def coerce_type(validations, attrs, required, opts)
431
433
  check_coerce_with(validations)
432
434
 
433
- return unless validations.key?(:coerce)
435
+ # Falsy check (not key?) is intentional: when a remountable API is first
436
+ # evaluated on its base instance (no configuration supplied yet),
437
+ # configuration[:some_type] evaluates to nil. Skipping instantiation
438
+ # here is correct — the real mounted instance will replay this step with
439
+ # the actual type value.
440
+ return unless validations[:coerce]
434
441
 
435
442
  coerce_options = {
436
443
  type: validations[:coerce],
@@ -438,9 +445,6 @@ module Grape
438
445
  message: validations[:coerce_message]
439
446
  }
440
447
  validate('coerce', coerce_options, attrs, required, opts)
441
- validations.delete(:coerce_with)
442
- validations.delete(:coerce)
443
- validations.delete(:coerce_message)
444
448
  end
445
449
 
446
450
  def guess_coerce_type(coerce_type, *values_list)
@@ -464,15 +468,15 @@ module Grape
464
468
  end
465
469
 
466
470
  def validate(type, options, attrs, required, opts)
467
- validator_options = {
468
- attributes: attrs,
469
- options: options,
470
- required: required,
471
- params_scope: self,
472
- opts: opts,
473
- validator_class: Validations.require_validator(type)
474
- }
475
- @api.inheritable_setting.namespace_stackable[:validations] = validator_options
471
+ validator_class = Validations.require_validator(type)
472
+ validator_instance = validator_class.new(
473
+ attrs,
474
+ options,
475
+ required,
476
+ self,
477
+ opts
478
+ )
479
+ @api.inheritable_setting.namespace_stackable[:validations] = validator_instance
476
480
  end
477
481
 
478
482
  def validate_value_coercion(coerce_type, *values_list)
@@ -5,10 +5,10 @@ module Grape
5
5
  class SingleAttributeIterator < AttributesIterator
6
6
  private
7
7
 
8
- def yield_attributes(val, attrs)
8
+ def yield_attributes(val)
9
9
  return if skip?(val)
10
10
 
11
- attrs.each do |attr_name|
11
+ @attrs.each do |attr_name|
12
12
  yield val, attr_name, empty?(val)
13
13
  end
14
14
  end
@@ -4,11 +4,14 @@ module Grape
4
4
  module Validations
5
5
  module Validators
6
6
  class AllOrNoneOfValidator < MultipleParamsBase
7
+ default_message_key :all_or_none
8
+
7
9
  def validate_params!(params)
8
- keys = keys_in_common(params)
9
- return if keys.empty? || keys.length == all_keys.length
10
+ known_keys = all_keys
11
+ keys = keys_in_common(params, known_keys)
12
+ return if keys.empty? || keys.length == @attrs.length
10
13
 
11
- raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:all_or_none))
14
+ validation_error!(known_keys)
12
15
  end
13
16
  end
14
17
  end
@@ -4,15 +4,20 @@ module Grape
4
4
  module Validations
5
5
  module Validators
6
6
  class AllowBlankValidator < Base
7
- def validate_param!(attr_name, params)
8
- return if (options_key?(:value) ? @option[:value] : @option) || !params.is_a?(Hash)
7
+ default_message_key :blank
8
+
9
+ def initialize(attrs, options, required, scope, opts)
10
+ super
11
+ @value = option_value
12
+ end
9
13
 
10
- value = params[attr_name]
11
- value = value.scrub if value.respond_to?(:valid_encoding?) && !value.valid_encoding?
14
+ def validate_param!(attr_name, params)
15
+ return if @value || !hash_like?(params)
12
16
 
17
+ value = scrub(params[attr_name])
13
18
  return if value == false || value.present?
14
19
 
15
- raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:blank))
20
+ validation_error!(attr_name)
16
21
  end
17
22
  end
18
23
  end
@@ -4,10 +4,13 @@ module Grape
4
4
  module Validations
5
5
  module Validators
6
6
  class AtLeastOneOfValidator < MultipleParamsBase
7
+ default_message_key :at_least_one
8
+
7
9
  def validate_params!(params)
8
- return unless keys_in_common(params).empty?
10
+ known_keys = all_keys
11
+ return if hash_like?(params) && known_keys.intersect?(params.keys.map { |attr| @scope.full_name(attr) })
9
12
 
10
- raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:at_least_one))
13
+ validation_error!(known_keys)
11
14
  end
12
15
  end
13
16
  end
@@ -3,24 +3,64 @@
3
3
  module Grape
4
4
  module Validations
5
5
  module Validators
6
+ # Base class for all parameter validators.
7
+ #
8
+ # == Freeze contract
9
+ # Validator instances are shared across requests and are frozen after
10
+ # initialization (via +.new+). All inputs (+options+, +opts+, +attrs+)
11
+ # arrive pre-frozen from the DSL boundary, so subclass ivars derived
12
+ # from them are frozen by construction. Lazy ivar assignment
13
+ # (e.g. +memoize+, <tt>||=</tt>) will raise +FrozenError+ at request time.
6
14
  class Base
15
+ include Grape::Util::Translation
16
+
7
17
  attr_reader :attrs
8
18
 
19
+ class << self
20
+ # Declares the default I18n message key used by +validation_error!+.
21
+ # Subclasses that only need a single fixed error message can declare it
22
+ # at the class level instead of overriding +initialize+:
23
+ #
24
+ # class MyValidator < Grape::Validations::Validators::Base
25
+ # default_message_key :my_error
26
+ # end
27
+ #
28
+ # The key is resolved through +message+, so a per-option +:message+
29
+ # override still takes precedence.
30
+ def default_message_key(key = nil)
31
+ if key
32
+ @default_message_key = key
33
+ else
34
+ @default_message_key || (superclass.respond_to?(:default_message_key) ? superclass.default_message_key : nil)
35
+ end
36
+ end
37
+
38
+ def new(...)
39
+ super.freeze
40
+ end
41
+
42
+ def inherited(klass)
43
+ super
44
+ Validations.register(klass)
45
+ end
46
+ end
47
+
9
48
  # Creates a new Validator from options specified
10
49
  # by a +requires+ or +optional+ directive during
11
50
  # parameter definition.
12
51
  # @param attrs [Array] names of attributes to which the Validator applies
13
- # @param options [Object] implementation-dependent Validator options
52
+ # @param options [Object] implementation-dependent Validator options; deep-frozen on assignment
14
53
  # @param required [Boolean] attribute(s) are required or optional
15
54
  # @param scope [ParamsScope] parent scope for this Validator
16
55
  # @param opts [Hash] additional validation options
17
56
  def initialize(attrs, options, required, scope, opts)
18
- @attrs = Array(attrs)
19
- @option = options
57
+ @attrs = Array(attrs).freeze
58
+ @options = Grape::Util::DeepFreeze.deep_freeze(options)
59
+ @option = @options # TODO: remove in next major release
20
60
  @required = required
21
61
  @scope = scope
22
- @fail_fast = opts[:fail_fast]
23
- @allow_blank = opts[:allow_blank]
62
+ @fail_fast, @allow_blank = opts.values_at(:fail_fast, :allow_blank)
63
+ @exception_message = message(self.class.default_message_key) if self.class.default_message_key
24
64
  end
25
65
 
26
66
  # Validates a given request.
@@ -34,13 +74,18 @@ module Grape
34
74
  validate!(request.params)
35
75
  end
36
76
 
77
+ def fail_fast?
78
+ @fail_fast
79
+ end
80
+
37
81
  # Validates a given parameter hash.
38
- # @note Override #validate if you need to access the entire request.
82
+ # @note Override #validate_param! for per-parameter validation,
83
+ # or #validate if you need access to the entire request.
39
84
  # @param params [Hash] parameters to validate
40
85
  # @raise [Grape::Exceptions::Validation] if validation failed
41
86
  # @return [void]
42
87
  def validate!(params)
43
- attributes = SingleAttributeIterator.new(self, @scope, params)
88
+ attributes = SingleAttributeIterator.new(@attrs, @scope, params)
44
89
  # we collect errors inside array because
45
90
  # there may be more than one error per field
46
91
  array_errors = []
@@ -49,7 +94,7 @@ module Grape
49
94
  next if !@scope.required? && empty_val
50
95
  next unless @scope.meets_dependency?(val, params)
51
96
 
52
- validate_param!(attr_name, val) if @required || (val.respond_to?(:key?) && val.key?(attr_name))
97
+ validate_param!(attr_name, val) if @required || (hash_like?(val) && val.key?(attr_name))
53
98
  rescue Grape::Exceptions::Validation => e
54
99
  array_errors << e
55
100
  end
@@ -57,23 +102,55 @@ module Grape
57
102
  raise Grape::Exceptions::ValidationArrayErrors.new(array_errors) if array_errors.any?
58
103
  end
59
104
 
60
- def self.inherited(klass)
61
- super
62
- Validations.register(klass)
105
+ protected
106
+
107
+ # Validates a single attribute. Override in subclasses.
108
+ # @param attr_name [Symbol, String] the attribute name
109
+ # @param params [Hash] the parameter hash containing the attribute
110
+ # @raise [Grape::Exceptions::Validation] if validation failed
111
+ # @return [void]
112
+ def validate_param!(attr_name, params)
113
+ raise NotImplementedError
114
+ end
115
+
116
+ private
117
+
118
+ def validation_error!(attr_name_or_params, message = @exception_message)
119
+ params = attr_name_or_params.is_a?(Array) ? attr_name_or_params : @scope.full_name(attr_name_or_params)
120
+ raise Grape::Exceptions::Validation.new(params:, message:)
63
121
  end
64
122
 
65
- def message(default_key = nil)
66
- options = instance_variable_get(:@option)
67
- options_key?(:message) ? options[:message] : default_key
123
+ def hash_like?(obj)
124
+ obj.respond_to?(:key?)
68
125
  end
69
126
 
70
127
  def options_key?(key, options = nil)
71
- options = instance_variable_get(:@option) if options.nil?
72
- options.respond_to?(:key?) && options.key?(key) && !options[key].nil?
128
+ current_options = options || @options
129
+ hash_like?(current_options) && current_options.key?(key) && !current_options[key].nil?
73
130
  end
74
131
 
75
- def fail_fast?
76
- @fail_fast
132
+ # Returns the effective message for a validation error.
133
+ # Prefers an explicit +:message+ option, then +default_key+.
134
+ # If both are nil, the block (if given) is called to compute a fallback —
135
+ # useful for validators that build a message Hash for deferred i18n interpolation.
136
+ # @example
137
+ # @exception_message = message(:presence) # symbol key or custom message
138
+ # @exception_message = message { build_hash_message } # computed fallback
139
+ def message(default_key = nil)
140
+ key = options_key?(:message) ? @options[:message] : default_key
141
+ return key unless key.nil?
142
+
143
+ yield if block_given?
144
+ end
145
+
146
+ def option_value
147
+ options_key?(:value) ? @options[:value] : @options
148
+ end
149
+
150
+ def scrub(value)
151
+ return value unless value.respond_to?(:valid_encoding?) && !value.valid_encoding?
152
+
153
+ value.scrub
77
154
  end
78
155
  end
79
156
  end