Skip to content

Commit b744723

Browse files
committed
Instantiate validators at definition time and fix thread safety
Validator instantiation 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 - Freeze validator instances after initialization via Base.new to prevent mutation across shared requests (shallow freeze) - 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 - Use case/when for message_key extraction in Exceptions::Validation - Guard LengthValidator against missing constraints and extract option validation into private methods to stay within complexity limits - Store zero-arity procs directly in ValuesValidator (consistent with ExceptValuesValidator) and document DB-backed lazy evaluation intent - Drop test-prof dependency and its spec config Thread safety for shared ParamsScope instances: - Introduce Grape::Validations::ScopeTracker to hold all per-request mutable state (array index and qualifying params) in a single Thread.current entry, keeping shared ParamsScope objects immutable - ScopeTracker.track { } wraps the validation run in Endpoint and ensures cleanup via ensure regardless of errors - AttributesIterator stores current array indices via ScopeTracker instead of mutating @index on the shared scope - ParamsScope#full_name reads the current index from ScopeTracker instead of @index; remove @index, reset_index, and attr_accessor - meets_dependency? stores qualifying array params in ScopeTracker instead of @params_meeting_dependency on the shared scope - Change element and parent from attr_accessor to attr_reader since setters were never used outside initialize
1 parent be7dede commit b744723

54 files changed

Lines changed: 2712 additions & 1679 deletions

Some content is hidden

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

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
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).
7+
* [#2657](https://github.com/ruby-grape/grape/pull/2657): Fix thread safety — per-request mutable state (array indices, qualifying params) moved to `Grape::Validations::ParamScopeTracker` using fiber-local storage (`Fiber[]`); `ParamsScope` instances are now frozen and shared safely across requests - [@ericproulx](https://github.com/ericproulx).
68
* Your contribution here.
79

810
#### 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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,110 @@
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+
#### `Grape::Exceptions::Base#translate_message` Supports Hash Messages
15+
16+
`translate_message` now accepts a Hash with a `:key` and interpolation parameters for deferred I18n translation:
17+
18+
```ruby
19+
# Symbol (unchanged)
20+
translate_message(:presence)
21+
22+
# Hash (new) — key + interpolation params, translated at error-raise time
23+
translate_message({ key: :length, min: 2, max: 5 })
24+
```
25+
26+
This is used by validators that need locale-sensitive messages with interpolation (e.g. `LengthValidator`, `SameAsValidator`).
27+
28+
#### `Grape::Exceptions::Validation` Changes
29+
30+
**`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.
31+
32+
**`params` is now always coerced to an array.** You can now pass a single string instead of wrapping it in an array:
33+
34+
```ruby
35+
# Before
36+
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')
37+
38+
# After (both work, single string is now accepted)
39+
Grape::Exceptions::Validation.new(params: 'my_param', message: 'is invalid')
40+
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')
41+
```
42+
43+
#### `Validators::Base` Method Visibility Changes
44+
45+
The following methods on `Grape::Validations::Validators::Base` are now **private**: `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.
46+
47+
New private helpers have been added:
48+
- `hash_like?(obj)` — returns `obj.respond_to?(:key?)`
49+
- `option_value` — returns `@option[:value]` if present, otherwise `@option`
50+
- `scrub(value)` — scrubs invalid-encoding strings
51+
- `translate_message(key, **)` — translates a message key using the `grape.errors.messages` I18n scope with fallback locale support
52+
53+
`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`.
54+
55+
#### `Validators::Base#message` Now Accepts a Block
56+
57+
`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:
58+
59+
```ruby
60+
# Before
61+
def message(default_key = nil)
62+
options_key?(:message) ? @option[:message] : default_key
63+
end
64+
65+
# After
66+
def message(default_key = nil)
67+
key = options_key?(:message) ? @option[:message] : default_key
68+
return key unless key.nil?
69+
70+
yield if block_given?
71+
end
72+
```
73+
74+
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.
75+
76+
#### `ContractScopeValidator` No Longer Inherits from `Base`
77+
78+
`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:
79+
80+
```ruby
81+
# Before
82+
ContractScopeValidator.new(attrs, options, required, scope, opts)
83+
84+
# After
85+
ContractScopeValidator.new(schema: contract)
86+
```
87+
88+
Because it no longer inherits from `Base`, it is not registered via `Validations.register` and will not appear in `Grape::Validations.validators`.
89+
90+
#### `endpoint_run_validators.grape` Notification No Longer Fires Without Validators
91+
92+
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.
93+
94+
#### Validator Constructor Caching
95+
96+
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.
97+
98+
#### Thread Safety: `ParamScopeTracker` and Fiber-Local State
99+
100+
`ParamsScope` instances are now shared across requests (frozen at definition time) and all per-request mutable state (array indices, qualifying params for `dependent_on`) lives in `Grape::Validations::ParamScopeTracker`, stored in a fiber-local variable (`Fiber[:grape_param_scope_tracker]`).
101+
102+
`Endpoint#run_validators` wraps validation in `ParamScopeTracker.track {}`, which sets up and tears down the tracker around each request. This is transparent for the standard request path.
103+
104+
**Impact on custom validators:** If you call `validator.validate!` or `@scope.full_name` directly outside of `Endpoint#run_validators` (e.g., in standalone tests), no tracker is active. Array indices in error messages will be `nil`, producing names like `items[][name]` instead of `items[0][name]`. Additionally, `ParamsScope#qualifying_params` returns an empty array (falling back to parent params) instead of the filtered array elements that satisfy a `dependent_on` condition. Wrap such calls in `Grape::Validations::ParamScopeTracker.track { }` if you need accurate indices or qualifying params.
105+
106+
**Fiber-local, not thread-local:** The tracker uses `Fiber[]` (Ruby 3.0+) rather than `Thread.current[]`. This ensures each fiber on fiber-based servers (e.g. Falcon) gets its own tracker, preventing cross-request state leakage.
107+
4108
### Upgrading to >= 3.1
5109

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

lib/grape/endpoint.rb

Lines changed: 7 additions & 14 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,11 +205,14 @@ 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.empty?
211+
209212
validation_errors = []
210213

211214
Grape::Validations::ParamScopeTracker.track do
212-
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request: request) do
215+
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request:) do
213216
validators.each do |validator|
214217
validator.validate(request)
215218
rescue Grape::Exceptions::Validation => e
@@ -222,7 +225,7 @@ def run_validators(validators, request)
222225
end
223226
end
224227

225-
validation_errors.any? && raise(Grape::Exceptions::ValidationErrors.new(errors: validation_errors, headers: header))
228+
raise(Grape::Exceptions::ValidationErrors.new(errors: validation_errors, headers: header)) if validation_errors.any?
226229
end
227230

228231
def run_filters(filters, type = :other)
@@ -239,16 +242,6 @@ def run_filters(filters, type = :other)
239242
end
240243
end
241244

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

lib/grape/exceptions/base.rb

Lines changed: 19 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
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
7+
8+
MESSAGE_STEPS = %w[problem summary resolution].to_h { |s| [s, s.capitalize] }.freeze
99

1010
attr_reader :status, :headers
1111

@@ -20,55 +20,37 @@ def [](index)
2020
__send__ index
2121
end
2222

23-
protected
23+
private
2424

2525
# TODO: translate attribute first
2626
# if BASE_ATTRIBUTES_KEY.key respond to a string message, then short_message is returned
2727
# 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)
28+
def compose_message(key, **)
29+
short_message = translate_message(key, **)
3030
return short_message unless short_message.is_a?(Hash)
3131

32-
each_steps(key, attributes).with_object(+'') do |detail_array, message|
33-
message << "\n#{detail_array[0]}:\n #{detail_array[1]}" unless detail_array[1].blank?
34-
end
35-
end
36-
37-
def each_steps(key, attributes)
38-
return enum_for(:each_steps, key, attributes) unless block_given?
39-
40-
yield 'Problem', translate_message(:"#{key}.problem", attributes)
41-
yield 'Summary', translate_message(:"#{key}.summary", attributes)
42-
yield 'Resolution', translate_message(:"#{key}.resolution", attributes)
32+
MESSAGE_STEPS.filter_map do |step, label|
33+
detail = translate_message(:"#{key}.#{step}", **)
34+
"\n#{label}:\n #{detail}" if detail.present?
35+
end.join
4336
end
4437

45-
def translate_attributes(keys, options = {})
38+
def translate_attributes(keys, **)
4639
keys.map do |key|
47-
translate("#{BASE_ATTRIBUTES_KEY}.#{key}", options.merge(default: key.to_s))
40+
translate(key, scope: 'grape.errors.attributes', default: key.to_s, **)
4841
end.join(', ')
4942
end
5043

51-
def translate_message(key, options = {})
52-
case key
44+
def translate_message(translation_key, **)
45+
case translation_key
5346
when Symbol
54-
translate("#{BASE_MESSAGES_KEY}.#{key}", options.merge(default: ''))
47+
translate(translation_key, scope: 'grape.errors.messages', **)
48+
when Hash
49+
translate(translation_key[:key], scope: 'grape.errors.messages', **translation_key.except(:key))
5550
when Proc
56-
key.call
57-
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
51+
translation_key.call
7052
else
71-
::I18n.translate(key, locale: FALLBACK_LOCALE, **options)
53+
translation_key
7254
end
7355
end
7456
end

lib/grape/exceptions/validation.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
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 = case message
12+
when Symbol, String then message
13+
when Hash then message[:key]
14+
when Proc then nil # Proc messages are evaluated at call time; no static key
15+
end
1216
message = translate_message(message)
1317
end
1418

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/deep_freeze.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
# Already-frozen objects (including Symbols, Integers, true/false/nil, and
12+
# any object that was previously frozen) are returned immediately via the
13+
# +obj.frozen?+ guard.
14+
#
15+
# Intentionally left unfrozen:
16+
# - Procs / lambdas — may be deferred DB-backed callables
17+
# - Coercers (e.g. ArrayCoercer) — use lazy ivar memoization at request time
18+
# - Classes / Modules — shared constants that must remain open
19+
# - ParamsScope — self-freezes at the end of its own initialize
20+
def deep_freeze(obj)
21+
return obj if obj.frozen?
22+
23+
case obj
24+
when Hash
25+
obj.each do |k, v|
26+
deep_freeze(k)
27+
deep_freeze(v)
28+
end
29+
obj.freeze
30+
when Array
31+
obj.each { |v| deep_freeze(v) }
32+
obj.freeze
33+
when String
34+
obj.freeze
35+
else
36+
obj
37+
end
38+
end
39+
end
40+
end
41+
end

lib/grape/util/translation.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
module Grape
4+
module Util
5+
module Translation
6+
FALLBACK_LOCALE = :en
7+
# Sentinel returned by I18n when a key is missing (passed as the default:
8+
# value). Using a named class rather than plain Object.new makes it
9+
# identifiable in debug output and immune to backends that call .to_s on
10+
# the default before returning it.
11+
MISSING = Class.new { def inspect = 'Grape::Util::Translation::MISSING' }.new.freeze
12+
private_constant :MISSING
13+
14+
private
15+
16+
# Extra keyword args (**) are forwarded verbatim to I18n as interpolation
17+
# variables (e.g. +min:+, +max:+ from LengthValidator's Hash message).
18+
# Callers must not pass unintended keyword arguments — any extra keyword
19+
# will silently become an I18n interpolation variable.
20+
def translate(key, default: MISSING, scope: :grape, locale: nil, **)
21+
i18n_opts = { default:, scope:, ** }
22+
i18n_opts[:locale] = locale if locale
23+
message = ::I18n.translate(key, **i18n_opts)
24+
return message unless message.equal?(MISSING)
25+
26+
effective_default = default.equal?(MISSING) ? [*Array(scope), key].join('.') : default
27+
return effective_default if (locale || ::I18n.locale) == FALLBACK_LOCALE
28+
29+
if ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
30+
effective_default
31+
else
32+
::I18n.translate(key, default: effective_default, scope:, locale: FALLBACK_LOCALE, **)
33+
end
34+
end
35+
end
36+
end
37+
end

0 commit comments

Comments
 (0)