Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#### Features

* [#2656](https://github.com/ruby-grape/grape/pull/2656): Remove useless instance_variable_defined? checks - [@ericproulx](https://github.com/ericproulx).
* [#2657](https://github.com/ruby-grape/grape/pull/2657): Instantiate validators at compile time - [@ericproulx](https://github.com/ericproulx).
* [#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).
* Your contribution here.

#### Fixes
Expand Down
1 change: 0 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ group :test do
gem 'rspec', '~> 3.13'
gem 'simplecov', '~> 0.21', require: false
gem 'simplecov-lcov', '~> 0.8', require: false
gem 'test-prof', require: false
end

platforms :jruby do
Expand Down
104 changes: 104 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,110 @@
Upgrading Grape
===============

### Upgrading to >= 3.2

#### Validators Instantiated at Definition Time

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.

#### `Grape::Util::Translation` Module

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`.

#### `Grape::Exceptions::Base#translate_message` Supports Hash Messages

`translate_message` now accepts a Hash with a `:key` and interpolation parameters for deferred I18n translation:

```ruby
# Symbol (unchanged)
translate_message(:presence)

# Hash (new) — key + interpolation params, translated at error-raise time
translate_message({ key: :length, min: 2, max: 5 })
```

This is used by validators that need locale-sensitive messages with interpolation (e.g. `LengthValidator`, `SameAsValidator`).

#### `Grape::Exceptions::Validation` Changes

**`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.

**`params` is now always coerced to an array.** You can now pass a single string instead of wrapping it in an array:

```ruby
# Before
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')

# After (both work, single string is now accepted)
Grape::Exceptions::Validation.new(params: 'my_param', message: 'is invalid')
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')
```

#### `Validators::Base` Method Visibility Changes

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.

New private helpers have been added:
- `hash_like?(obj)` — returns `obj.respond_to?(:key?)`
- `option_value` — returns `@option[:value]` if present, otherwise `@option`
- `scrub(value)` — scrubs invalid-encoding strings
- `translate_message(key, **)` — translates a message key using the `grape.errors.messages` I18n scope with fallback locale support

`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`.

#### `Validators::Base#message` Now Accepts a Block

`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:

```ruby
# Before
def message(default_key = nil)
options_key?(:message) ? @option[:message] : default_key
end

# After
def message(default_key = nil)
key = options_key?(:message) ? @option[:message] : default_key
return key unless key.nil?

yield if block_given?
end
```

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.

#### `ContractScopeValidator` No Longer Inherits from `Base`

`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:

```ruby
# Before
ContractScopeValidator.new(attrs, options, required, scope, opts)

# After
ContractScopeValidator.new(schema: contract)
```

Because it no longer inherits from `Base`, it is not registered via `Validations.register` and will not appear in `Grape::Validations.validators`.

#### `endpoint_run_validators.grape` Notification No Longer Fires Without Validators

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.

#### Validator Constructor Caching

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.

#### Thread Safety: `ParamScopeTracker` and Fiber-Local State

`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]`).

`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.

**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.

**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.

### Upgrading to >= 3.1

#### Explicit kwargs for `namespace` and `route_param`
Expand Down
2 changes: 1 addition & 1 deletion lib/grape/dsl/parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def map_params(params, element, is_array = false)
# @return hash of parameters relevant for the current scope
# @api private
def params(params)
params = @parent.params_meeting_dependency.presence || @parent.params(params) if @parent
params = @parent.qualifying_params.presence || @parent.params(params) if @parent
params = map_params(params, @element) if @element
params
end
Expand Down
39 changes: 17 additions & 22 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def run
status 204
else
run_filters before_validations, :before_validation
run_validators validations, request
run_validators request: request
run_filters after_validations, :after_validation
response_object = execute
end
Expand Down Expand Up @@ -205,22 +205,27 @@ def execute
end
end

def run_validators(validators, request)
def run_validators(request:)
validators = inheritable_setting.route[:saved_validations]
return if validators.empty?

validation_errors = []

ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request: request) do
validators.each do |validator|
validator.validate(request)
rescue Grape::Exceptions::Validation => e
validation_errors << e
break if validator.fail_fast?
rescue Grape::Exceptions::ValidationArrayErrors => e
validation_errors.concat e.errors
break if validator.fail_fast?
Grape::Validations::ParamScopeTracker.track do
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request:) do
validators.each do |validator|
validator.validate(request)
rescue Grape::Exceptions::Validation => e
validation_errors << e
break if validator.fail_fast?
rescue Grape::Exceptions::ValidationArrayErrors => e
validation_errors.concat e.errors
break if validator.fail_fast?
end
end
end

validation_errors.any? && raise(Grape::Exceptions::ValidationErrors.new(errors: validation_errors, headers: header))
raise(Grape::Exceptions::ValidationErrors.new(errors: validation_errors, headers: header)) if validation_errors.any?
end

def run_filters(filters, type = :other)
Expand All @@ -237,16 +242,6 @@ def run_filters(filters, type = :other)
end
end

def validations
saved_validations = inheritable_setting.route[:saved_validations]
return if saved_validations.nil?
return enum_for(:validations) unless block_given?

saved_validations.each do |saved_validation|
yield Grape::Validations::ValidatorFactory.create_validator(saved_validation)
end
end

def options?
options[:options_route_enabled] &&
env[Rack::REQUEST_METHOD] == Rack::OPTIONS
Expand Down
56 changes: 19 additions & 37 deletions lib/grape/exceptions/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
module Grape
module Exceptions
class Base < StandardError
BASE_MESSAGES_KEY = 'grape.errors.messages'
BASE_ATTRIBUTES_KEY = 'grape.errors.attributes'
FALLBACK_LOCALE = :en
include Grape::Util::Translation

MESSAGE_STEPS = %w[problem summary resolution].to_h { |s| [s, s.capitalize] }.freeze

attr_reader :status, :headers

Expand All @@ -20,55 +20,37 @@ def [](index)
__send__ index
end

protected
private

# TODO: translate attribute first
# if BASE_ATTRIBUTES_KEY.key respond to a string message, then short_message is returned
# if BASE_ATTRIBUTES_KEY.key respond to a Hash, means it may have problem , summary and resolution
def compose_message(key, **attributes)
short_message = translate_message(key, attributes)
def compose_message(key, **)
short_message = translate_message(key, **)
return short_message unless short_message.is_a?(Hash)

each_steps(key, attributes).with_object(+'') do |detail_array, message|
message << "\n#{detail_array[0]}:\n #{detail_array[1]}" unless detail_array[1].blank?
end
end

def each_steps(key, attributes)
return enum_for(:each_steps, key, attributes) unless block_given?

yield 'Problem', translate_message(:"#{key}.problem", attributes)
yield 'Summary', translate_message(:"#{key}.summary", attributes)
yield 'Resolution', translate_message(:"#{key}.resolution", attributes)
MESSAGE_STEPS.filter_map do |step, label|
detail = translate_message(:"#{key}.#{step}", **)
"\n#{label}:\n #{detail}" if detail.present?
end.join
end

def translate_attributes(keys, options = {})
def translate_attributes(keys, **)
keys.map do |key|
translate("#{BASE_ATTRIBUTES_KEY}.#{key}", options.merge(default: key.to_s))
translate(key, scope: 'grape.errors.attributes', default: key.to_s, **)
end.join(', ')
end

def translate_message(key, options = {})
case key
def translate_message(translation_key, **)
case translation_key
when Symbol
translate("#{BASE_MESSAGES_KEY}.#{key}", options.merge(default: ''))
translate(translation_key, scope: 'grape.errors.messages', **)
when Hash
translate(translation_key[:key], scope: 'grape.errors.messages', **translation_key.except(:key))
when Proc
key.call
else
key
end
end

def translate(key, options)
message = ::I18n.translate(key, **options)
message.presence || fallback_message(key, options)
end

def fallback_message(key, options)
if ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
key
translation_key.call
else
::I18n.translate(key, locale: FALLBACK_LOCALE, **options)
translation_key
end
end
end
Expand Down
10 changes: 7 additions & 3 deletions lib/grape/exceptions/validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
module Grape
module Exceptions
class Validation < Base
attr_accessor :params, :message_key
attr_reader :params, :message_key

def initialize(params:, message: nil, status: nil, headers: nil)
@params = params
@params = params.is_a?(Array) ? params : [params]
if message
@message_key = message if message.is_a?(Symbol)
@message_key = case message
when Symbol, String then message
when Hash then message[:key]
when Proc then nil # Proc messages are evaluated at call time; no static key
end
message = translate_message(message)
end

Expand Down
10 changes: 4 additions & 6 deletions lib/grape/exceptions/validation_errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
module Grape
module Exceptions
class ValidationErrors < Base
ERRORS_FORMAT_KEY = 'grape.errors.format'
DEFAULT_ERRORS_FORMAT = '%<attributes>s %<message>s'

include Enumerable

attr_reader :errors
Expand Down Expand Up @@ -38,9 +35,10 @@ def to_json(*_opts)

def full_messages
messages = map do |attributes, error|
I18n.t(
ERRORS_FORMAT_KEY,
default: DEFAULT_ERRORS_FORMAT,
translate(
:format,
scope: 'grape.errors',
default: '%<attributes>s %<message>s',
attributes: translate_attributes(attributes),
message: error.message
)
Expand Down
41 changes: 41 additions & 0 deletions lib/grape/util/deep_freeze.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Grape
module Util
module DeepFreeze
module_function

# Recursively freezes Hash (keys and values), Array (elements), and String
# objects. All other types are returned as-is.
#
# Already-frozen objects (including Symbols, Integers, true/false/nil, and
# any object that was previously frozen) are returned immediately via the
# +obj.frozen?+ guard.
#
# Intentionally left unfrozen:
# - Procs / lambdas — may be deferred DB-backed callables
# - Coercers (e.g. ArrayCoercer) — use lazy ivar memoization at request time
# - Classes / Modules — shared constants that must remain open
# - ParamsScope — self-freezes at the end of its own initialize
def deep_freeze(obj)
return obj if obj.frozen?

case obj
when Hash
obj.each do |k, v|
deep_freeze(k)
deep_freeze(v)
end
obj.freeze
when Array
obj.each { |v| deep_freeze(v) }
obj.freeze
when String
obj.freeze
else
obj
end
end
end
end
end
Loading