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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

* [#2656](https://github.com/ruby-grape/grape/pull/2656): Remove useless instance_variable_defined? checks - [@ericproulx](https://github.com/ericproulx).
* [#2619](https://github.com/ruby-grape/grape/pull/2619): Remove TOC from README.md and danger-toc check - [@alexanderadam](https://github.com/alexanderadam).
* [#2662](https://github.com/ruby-grape/grape/pull/2662): Extract `Grape::Util::Translation` for shared I18n fallback logic - [@ericproulx](https://github.com/ericproulx).
* [#2663](https://github.com/ruby-grape/grape/pull/2663): Refactor `ParamsScope` and `Parameters` DSL to use named kwargs - [@ericproulx](https://github.com/ericproulx).
* [#2664](https://github.com/ruby-grape/grape/pull/2664): Drop `test-prof` dependency - [@ericproulx](https://github.com/ericproulx).
* [#2665](https://github.com/ruby-grape/grape/pull/2665): Pass `attrs` directly to `AttributesIterator` instead of `validator` - [@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 @@ -31,7 +31,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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1896,6 +1896,31 @@ Grape supports I18n for parameter-related error messages, but will fallback to E

In case your app enforces available locales only and :en is not included in your available locales, Grape cannot fall back to English and will return the translation key for the error message. To avoid this behaviour, either provide a translation for your default locale or add :en to your available locales.

Custom validators that inherit from `Grape::Validations::Validators::Base` have access to a `translate` helper (see `Grape::Util::Translation`) and should use it instead of calling `I18n` directly. It applies the same `:en` fallback as built-in validators, defaults `scope` to `'grape.errors.messages'`, and handles interpolation without needing `format`:

```ruby
# Good — scope defaults to 'grape.errors.messages', interpolation forwarded automatically
translate(:special, min: 2, max: 10)

# Bad — format is unnecessary and risks conflicting with I18n reserved keys
format I18n.t(:special, scope: 'grape.errors.messages'), min: 2, max: 10
```

Example custom validator:

```ruby
class SpecialValidator < Grape::Validations::Validators::Base
def validate_param!(attr_name, params)
return if valid?(params[attr_name])

raise Grape::Exceptions::Validation.new(
params: [@scope.full_name(attr_name)],
message: translate(:special, min: 2, max: 10)
)
end
end
```

### Custom Validation messages

Grape supports custom validation messages for parameter-related and coerce-related error messages.
Expand Down
25 changes: 25 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,31 @@ with({ type: String }) { ... }

See [#2663](https://github.com/ruby-grape/grape/pull/2663) for more information.

#### Custom validators: use `translate` instead of `I18n` directly

`Grape::Util::Translation` is now included in `Grape::Validations::Validators::Base`. Custom validators that previously called `I18n.t` or `I18n.translate` directly should switch to the `translate`, which provides the same `:en` fallback logic used by all built-in validators.

Key points:
- `scope` defaults to `'grape.errors.messages'` — no need to specify it for standard error message keys.
- Interpolation variables are passed directly to I18n.
- `format` is no longer needed — `translate` returns the fully interpolated string.

```ruby
# Before
raise Grape::Exceptions::Validation.new(
params: [@scope.full_name(attr_name)],
message: format(I18n.t(:my_key, scope: 'grape.errors.messages'), min: 2, max: 10)
)

# After
raise Grape::Exceptions::Validation.new(
params: [@scope.full_name(attr_name)],
message: translate(:my_key, min: 2, max: 10)
)
```

See [#2662](https://github.com/ruby-grape/grape/pull/2662) for more information.

### Upgrading to >= 3.1

#### Explicit kwargs for `namespace` and `route_param`
Expand Down
21 changes: 7 additions & 14 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,11 +205,14 @@ 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 = []

Grape::Validations::ParamScopeTracker.track do
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request: request) 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
Expand All @@ -222,7 +225,7 @@ def run_validators(validators, request)
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 @@ -239,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
62 changes: 18 additions & 44 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,29 @@ 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)
end

def translate_attributes(keys, options = {})
keys.map do |key|
translate("#{BASE_ATTRIBUTES_KEY}.#{key}", options.merge(default: key.to_s))
end.join(', ')
MESSAGE_STEPS.filter_map do |step, label|
detail = translate_message(:"#{key}.#{step}", **)
"\n#{label}:\n #{detail}" if detail.present?
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, **)
when Hash
translation_key => { key:, **opts }
translate(key, **opts)
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
9 changes: 6 additions & 3 deletions lib/grape/exceptions/validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
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 = Array(params)
if message
@message_key = message if message.is_a?(Symbol)
@message_key = case message
when Symbol then message
when Hash then message[:key]
end
message = translate_message(message)
end

Expand Down
18 changes: 12 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,16 +35,25 @@ 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
)
end
messages.uniq!
messages
end

private

def translate_attributes(keys)
keys.map do |key|
translate(key, scope: 'grape.errors.attributes', default: key.to_s)
end.join(', ')
end
end
end
end
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
42 changes: 42 additions & 0 deletions lib/grape/util/translation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

module Grape
module Util
module Translation
FALLBACK_LOCALE = :en
private_constant :FALLBACK_LOCALE
# Sentinel returned by I18n when a key is missing (passed as the default:
# value). Using a named class rather than plain Object.new makes it
# identifiable in debug output and immune to backends that call .to_s on
# the default before returning it.
MISSING = Class.new { def inspect = 'Grape::Util::Translation::MISSING' }.new.freeze
private_constant :MISSING

private

# Extra keyword args (**) are forwarded verbatim to I18n as interpolation
# variables (e.g. +min:+, +max:+ from LengthValidator's Hash message).
# Callers must not pass unintended keyword arguments — any extra keyword
# will silently become an I18n interpolation variable.
def translate(key, default: MISSING, scope: 'grape.errors.messages', locale: nil, **)
i18n_opts = { default:, scope:, ** }
i18n_opts[:locale] = locale if locale
message = ::I18n.translate(key, **i18n_opts)
return message unless message.equal?(MISSING)

effective_default = default.equal?(MISSING) ? [*Array(scope), key].join('.') : default
return effective_default if fallback_locale?(locale) || fallback_locale_unavailable?

::I18n.translate(key, default: effective_default, scope:, locale: FALLBACK_LOCALE, **)
end

def fallback_locale?(locale)
(locale || ::I18n.locale) == FALLBACK_LOCALE
end

def fallback_locale_unavailable?
::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
end
end
end
end
8 changes: 4 additions & 4 deletions lib/grape/validations/attributes_iterator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ class AttributesIterator

attr_reader :scope

def initialize(validator, scope, params)
def initialize(attrs, scope, params)
@attrs = attrs
@scope = scope
@attrs = validator.attrs
@original_params = scope.params(params)
@params = Array.wrap(@original_params)
end
Expand Down Expand Up @@ -41,7 +41,7 @@ def do_each(params_to_process, parent_indices = [], &block)
store_indices(target, index, parent_indices) if target
end

yield_attributes(resource_params, @attrs, &block)
yield_attributes(resource_params, &block)
end
end

Expand All @@ -61,7 +61,7 @@ def store_indices(target_scope, index, parent_indices)
tracker.store_index(target_scope, index)
end

def yield_attributes(_resource_params, _attrs)
def yield_attributes(_resource_params)
raise NotImplementedError
end

Expand Down
Loading