Skip to content

Commit e16efcf

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 e16efcf

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2374
-1630
lines changed

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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,102 @@
11
Upgrading Grape
22
===============
33

4+
### Upgrading to >= 3.2
5+
6+
#### Validators Instantiated at Definition Time
7+
8+
Previously, validators were instantiated at request time but they are now instantiated at definition time. This reduces object allocations since instances are reused across requests.
9+
10+
#### `Grape::Util::Translation` Module
11+
12+
I18n translation logic (translate with fallback locale) has been extracted into `Grape::Util::Translation`, included by both `Grape::Exceptions::Base` and `Grape::Validations::Validators::Base`. The `FALLBACK_LOCALE` constant has moved from `Grape::Exceptions::Base` to `Grape::Util::Translation`.
13+
14+
When `I18n.enforce_available_locales` is `true` and `:en` is not in `I18n.available_locales`, the fallback now returns the bare key as a string (e.g. `"presence"`) instead of the full scope path (e.g. `"grape.errors.messages.presence"`).
15+
16+
#### `Grape::Exceptions::Base#translate_message` Supports Hash Messages
17+
18+
`translate_message` now accepts a Hash with a `:key` and interpolation parameters for deferred I18n translation:
19+
20+
```ruby
21+
# Symbol (unchanged)
22+
translate_message(:presence)
23+
24+
# Hash (new) — key + interpolation params, translated at error-raise time
25+
translate_message({ key: :length, min: 2, max: 5 })
26+
```
27+
28+
This is used by validators that need locale-sensitive messages with interpolation (e.g. `LengthValidator`, `SameAsValidator`).
29+
30+
#### `Grape::Exceptions::Validation` Changes
31+
32+
**`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.
33+
34+
**`params` is now always coerced to an array.** You can now pass a single string instead of wrapping it in an array:
35+
36+
```ruby
37+
# Before
38+
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')
39+
40+
# After (both work, single string is now accepted)
41+
Grape::Exceptions::Validation.new(params: 'my_param', message: 'is invalid')
42+
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')
43+
```
44+
45+
#### `Validators::Base` Method Visibility Changes
46+
47+
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.
48+
49+
New private helpers have been added:
50+
- `hash_like?(obj)` — returns `obj.respond_to?(:key?)`
51+
- `option_value` — returns `@option[:value]` if present, otherwise `@option`
52+
- `scrub(value)` — scrubs invalid-encoding strings
53+
- `translate_message(key, **)` — translates a message key using the `grape.errors.messages` I18n scope with fallback locale support
54+
55+
`validate_param!` now has a base implementation that raises `NotImplementedError`. Custom validators that override `validate!` directly are unaffected, but any subclass that relies on `validate_param!` being absent (e.g. calling `super` expecting no-op behaviour) will now receive a `NotImplementedError`.
56+
57+
#### `Validators::Base#message` Now Accepts a Block
58+
59+
`message` now accepts an optional block for lazy default message generation. When no custom `:message` option is set and no `default_key` is provided, the block is called:
60+
61+
```ruby
62+
# Before
63+
def message(default_key = nil)
64+
options_key?(:message) ? @option[:message] : default_key
65+
end
66+
67+
# After
68+
def message(default_key = nil)
69+
key = options_key?(:message) ? @option[:message] : default_key
70+
return key if key
71+
72+
yield if block_given?
73+
end
74+
```
75+
76+
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.
77+
78+
#### `ContractScopeValidator` No Longer Inherits from `Base`
79+
80+
`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:
81+
82+
```ruby
83+
# Before
84+
ContractScopeValidator.new(attrs, options, required, scope, opts)
85+
86+
# After
87+
ContractScopeValidator.new(schema: contract)
88+
```
89+
90+
Because it no longer inherits from `Base`, it is not registered via `Validations.register` and will not appear in `Grape::Validations.validators`.
91+
92+
#### `endpoint_run_validators.grape` Notification No Longer Fires Without Validators
93+
94+
The `endpoint_run_validators.grape` ActiveSupport notification is no longer emitted for routes that have no validators. Previously it fired unconditionally (with an empty `validators` array); now the instrumentation block is skipped entirely via an early return. If your observability or tracing code subscribes to this notification and expects it for every request, you will need to handle its absence for validator-free routes.
95+
96+
#### Validator Constructor Caching
97+
98+
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.
99+
4100
### Upgrading to >= 3.1
5101

6102
#### 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: 19 additions & 32 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

@@ -20,55 +18,44 @@ def [](index)
2018
__send__ index
2119
end
2220

23-
protected
21+
private
2422

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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
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
11-
@message_key = message if message.is_a?(Symbol)
11+
@message_key = message.is_a?(Hash) ? message[:key] : (message if message.is_a?(Symbol))
1212
message = translate_message(message)
1313
end
1414

lib/grape/exceptions/validation_errors.rb

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
module Grape
44
module Exceptions
55
class ValidationErrors < Base
6-
ERRORS_FORMAT_KEY = 'grape.errors.format'
7-
DEFAULT_ERRORS_FORMAT = '%<attributes>s %<message>s'
8-
96
include Enumerable
107

118
attr_reader :errors
@@ -38,9 +35,10 @@ def to_json(*_opts)
3835

3936
def full_messages
4037
messages = map do |attributes, error|
41-
I18n.t(
42-
ERRORS_FORMAT_KEY,
43-
default: DEFAULT_ERRORS_FORMAT,
38+
translate(
39+
:format,
40+
scope: 'grape.errors',
41+
default: '%<attributes>s %<message>s',
4442
attributes: translate_attributes(attributes),
4543
message: error.message
4644
)

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? || ::I18n.locale == FALLBACK_LOCALE
13+
14+
if ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
15+
key.to_s
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)

0 commit comments

Comments
 (0)