Skip to content

Commit 7a04c46

Browse files
Instantiate validators at definition time
- Store validator instances in ParamsScope/ContractScope and have Endpoint#run_validators read them directly - Remove ValidatorFactory indirection and eagerly compute validator messages/options in constructors - Extract Grape::Util::Translation module shared by Exceptions::Base and Validators::Base for I18n translate with fallback locale - Support Hash messages in translate_message for deferred translation with interpolation parameters (e.g. { key: :length, min: 2 }) - Normalize Grape::Exceptions::Validation params handling and refactor validator specs to define routes per example group - Drop test-prof dependency and its spec config Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ddc3b8c commit 7a04c46

40 files changed

Lines changed: 2290 additions & 1621 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#### Features
44

55
* [#2656](https://github.com/ruby-grape/grape/pull/2656): Remove useless instance_variable_defined? checks - [@ericproulx](https://github.com/ericproulx).
6+
* [#2657](https://github.com/ruby-grape/grape/pull/2657): Instantiate validators at compile time - [@ericproulx](https://github.com/ericproulx).
67
* Your contribution here.
78

89
#### Fixes

Gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ group :test do
3232
gem 'rspec', '~> 3.13'
3333
gem 'simplecov', '~> 0.21', require: false
3434
gem 'simplecov-lcov', '~> 0.8', require: false
35-
gem 'test-prof', require: false
3635
end
3736

3837
platforms :jruby do

UPGRADING.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,70 @@
11
Upgrading Grape
22
===============
33

4+
### Upgrading to >= 3.2
5+
6+
#### Validators Instantiated at Compile Time
7+
8+
Previously, validators were instantiated at request time but they are now instantiated at compile time. Validators are thread safe since their initial state always stay the same. It also reduces object allocations overtime since instances are reused.
9+
10+
#### `Grape::Exceptions::Validation` Changes
11+
12+
**`params` and `message_key` are now read-only.** `attr_accessor` has been changed to `attr_reader`. If you were assigning to these after initialization, set them via the constructor keyword arguments instead.
13+
14+
**`params` is now always coerced to an array.** You can now pass a single string instead of wrapping it in an array:
15+
16+
```ruby
17+
# Before
18+
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')
19+
20+
# After (both work, single string is now accepted)
21+
Grape::Exceptions::Validation.new(params: 'my_param', message: 'is invalid')
22+
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')
23+
```
24+
25+
#### `Validators::Base` Method Visibility Changes
26+
27+
The following methods on `Grape::Validations::Validators::Base` are now **private**: `validate!`, `message`, `options_key?`. If your custom validator subclass calls these via `super` from a private method, no change is needed. If you were calling them from outside the class, you'll need to adjust.
28+
29+
Three new private helpers have been added:
30+
- `hash_like?(obj)` — returns `obj.respond_to?(:key?)`
31+
- `option_value` — returns `@option[:value]` if present, otherwise `@option`
32+
- `scrub(value)` — scrubs invalid-encoding strings
33+
34+
#### `Validators::Base#message` Now Accepts a Block
35+
36+
`message` now accepts an optional block for lazy default message generation. The block is only called when no custom `:message` option is set and no `default_key` is provided:
37+
38+
```ruby
39+
# Before
40+
def message(default_key = nil)
41+
options_key?(:message) ? @option[:message] : default_key
42+
end
43+
44+
# After
45+
def message(default_key = nil)
46+
options_key?(:message) ? @option[:message] : default_key || yield
47+
end
48+
```
49+
50+
If your custom validator overrides `message` or passes a `default_key`, the behavior is unchanged. If you relied on `message` returning `nil` when no custom message and no default key were set, it now yields to the block instead.
51+
52+
#### `ContractScopeValidator` No Longer Inherits from `Base`
53+
54+
`ContractScopeValidator` is now a standalone class that no longer inherits from `Grape::Validations::Validators::Base`. Its constructor takes a single `schema:` keyword argument instead of the standard 5-argument validator signature:
55+
56+
```ruby
57+
# Before
58+
ContractScopeValidator.new(attrs, options, required, scope, opts)
59+
60+
# After
61+
ContractScopeValidator.new(schema: contract)
62+
```
63+
64+
#### Validator Constructor Caching
65+
66+
All built-in validators now eagerly compute and cache values in their constructors (exception messages, option values, lambdas for proc-based defaults/values). This is transparent to API consumers but relevant if you subclass built-in validators and override `initialize` — ensure you call `super` so caching is properly set up.
67+
468
### Upgrading to >= 3.1
569

670
#### Explicit kwargs for `namespace` and `route_param`

lib/grape/endpoint.rb

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def run
176176
status 204
177177
else
178178
run_filters before_validations, :before_validation
179-
run_validators validations, request
179+
run_validators request: request
180180
run_filters after_validations, :after_validation
181181
response_object = execute
182182
end
@@ -205,10 +205,13 @@ def execute
205205
end
206206
end
207207

208-
def run_validators(validators, request)
208+
def run_validators(request:)
209+
validators = inheritable_setting.route[:saved_validations]
210+
return if validators.blank?
211+
209212
validation_errors = []
210213

211-
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request: request) do
214+
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request:) do
212215
validators.each do |validator|
213216
validator.validate(request)
214217
rescue Grape::Exceptions::Validation => e
@@ -237,16 +240,6 @@ def run_filters(filters, type = :other)
237240
end
238241
end
239242

240-
def validations
241-
saved_validations = inheritable_setting.route[:saved_validations]
242-
return if saved_validations.nil?
243-
return enum_for(:validations) unless block_given?
244-
245-
saved_validations.each do |saved_validation|
246-
yield Grape::Validations::ValidatorFactory.create_validator(saved_validation)
247-
end
248-
end
249-
250243
def options?
251244
options[:options_route_enabled] &&
252245
env[Rack::REQUEST_METHOD] == Rack::OPTIONS

lib/grape/exceptions/base.rb

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
module Grape
44
module Exceptions
55
class Base < StandardError
6-
BASE_MESSAGES_KEY = 'grape.errors.messages'
7-
BASE_ATTRIBUTES_KEY = 'grape.errors.attributes'
8-
FALLBACK_LOCALE = :en
6+
include Grape::Util::Translation
97

108
attr_reader :status, :headers
119

@@ -25,50 +23,39 @@ def [](index)
2523
# TODO: translate attribute first
2624
# if BASE_ATTRIBUTES_KEY.key respond to a string message, then short_message is returned
2725
# if BASE_ATTRIBUTES_KEY.key respond to a Hash, means it may have problem , summary and resolution
28-
def compose_message(key, **attributes)
29-
short_message = translate_message(key, attributes)
26+
def compose_message(key, **)
27+
short_message = translate_message(key, **)
3028
return short_message unless short_message.is_a?(Hash)
3129

32-
each_steps(key, attributes).with_object(+'') do |detail_array, message|
30+
each_steps(key, **).with_object(+'') do |detail_array, message|
3331
message << "\n#{detail_array[0]}:\n #{detail_array[1]}" unless detail_array[1].blank?
3432
end
3533
end
3634

37-
def each_steps(key, attributes)
38-
return enum_for(:each_steps, key, attributes) unless block_given?
35+
def each_steps(key, **)
36+
return enum_for(:each_steps, key, **) unless block_given?
3937

40-
yield 'Problem', translate_message(:"#{key}.problem", attributes)
41-
yield 'Summary', translate_message(:"#{key}.summary", attributes)
42-
yield 'Resolution', translate_message(:"#{key}.resolution", attributes)
38+
yield 'Problem', translate_message(:"#{key}.problem", **)
39+
yield 'Summary', translate_message(:"#{key}.summary", **)
40+
yield 'Resolution', translate_message(:"#{key}.resolution", **)
4341
end
4442

45-
def translate_attributes(keys, options = {})
43+
def translate_attributes(keys, **)
4644
keys.map do |key|
47-
translate("#{BASE_ATTRIBUTES_KEY}.#{key}", options.merge(default: key.to_s))
45+
translate(key, scope: 'grape.errors.attributes', default: key.to_s, **)
4846
end.join(', ')
4947
end
5048

51-
def translate_message(key, options = {})
52-
case key
49+
def translate_message(translation_key, **)
50+
case translation_key
5351
when Symbol
54-
translate("#{BASE_MESSAGES_KEY}.#{key}", options.merge(default: ''))
52+
translate(translation_key, scope: 'grape.errors.messages', **)
53+
when Hash
54+
translate(translation_key[:key], scope: 'grape.errors.messages', **translation_key.except(:key))
5555
when Proc
56-
key.call
56+
translation_key.call
5757
else
58-
key
59-
end
60-
end
61-
62-
def translate(key, options)
63-
message = ::I18n.translate(key, **options)
64-
message.presence || fallback_message(key, options)
65-
end
66-
67-
def fallback_message(key, options)
68-
if ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
69-
key
70-
else
71-
::I18n.translate(key, locale: FALLBACK_LOCALE, **options)
58+
translation_key
7259
end
7360
end
7461
end

lib/grape/exceptions/validation.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
module Grape
44
module Exceptions
55
class Validation < Base
6-
attr_accessor :params, :message_key
6+
attr_reader :params, :message_key
77

88
def initialize(params:, message: nil, status: nil, headers: nil)
9-
@params = params
9+
@params = params.is_a?(Array) ? params : [params]
1010
if message
1111
@message_key = message if message.is_a?(Symbol)
1212
message = translate_message(message)

lib/grape/util/translation.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module Grape
4+
module Util
5+
module Translation
6+
FALLBACK_LOCALE = :en
7+
8+
private
9+
10+
def translate(key, default: '', scope: nil, **)
11+
message = ::I18n.translate(key, default:, scope:, **)
12+
return message if message.present?
13+
14+
if ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
15+
scope ? "#{scope}.#{key}" : key
16+
else
17+
::I18n.translate(key, default:, scope:, locale: FALLBACK_LOCALE, **)
18+
end
19+
end
20+
end
21+
end
22+
end

lib/grape/validations/contract_scope.rb

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,7 @@ def initialize(api, contract = nil, &block)
2121
end
2222

2323
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)
3125
end
3226
end
3327
end

lib/grape/validations/params_scope.rb

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ def validates(attrs, validations)
357357
# Before we run the rest of the validators, let's handle
358358
# whatever coercion so that we are working with correctly
359359
# type casted values
360-
coerce_type validations, attrs, required, opts
360+
coerce_type validations.extract!(:coerce, :coerce_with, :coerce_message), attrs, required, opts
361361

362362
validations.each do |type, options|
363363
# Don't try to look up validators for documentation params that don't have one.
@@ -430,17 +430,14 @@ def check_coerce_with(validations)
430430
def coerce_type(validations, attrs, required, opts)
431431
check_coerce_with(validations)
432432

433-
return unless validations.key?(:coerce)
433+
return unless validations[:coerce]
434434

435435
coerce_options = {
436436
type: validations[:coerce],
437437
method: validations[:coerce_with],
438438
message: validations[:coerce_message]
439439
}
440440
validate('coerce', coerce_options, attrs, required, opts)
441-
validations.delete(:coerce_with)
442-
validations.delete(:coerce)
443-
validations.delete(:coerce_message)
444441
end
445442

446443
def guess_coerce_type(coerce_type, *values_list)
@@ -464,15 +461,15 @@ def check_incompatible_option_values(default, values, except_values)
464461
end
465462

466463
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
464+
validator_class = Validations.require_validator(type)
465+
validator_instance = validator_class.new(
466+
attrs,
467+
options,
468+
required,
469+
self,
470+
opts
471+
)
472+
@api.inheritable_setting.namespace_stackable[:validations] = validator_instance
476473
end
477474

478475
def validate_value_coercion(coerce_type, *values_list)

lib/grape/validations/validator_factory.rb

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)