Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#### Features

* [#2662](https://github.com/ruby-grape/grape/pull/2662): Extract `Grape::Util::Translation` for shared I18n fallback logic - [@ericproulx](https://github.com/ericproulx).
* [#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).
* [#2663](https://github.com/ruby-grape/grape/pull/2663): Refactor `ParamsScope` and `Parameters` DSL to use named kwargs - [@ericproulx](https://github.com/ericproulx).
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
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
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
2 changes: 2 additions & 0 deletions lib/grape/validations/validators/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module Grape
module Validations
module Validators
class Base
include Grape::Util::Translation

attr_reader :attrs

# Creates a new Validator from options specified
Expand Down
8 changes: 4 additions & 4 deletions lib/grape/validations/validators/length_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ def build_message
if options_key?(:message)
@option[:message]
elsif @min && @max
format I18n.t(:length, scope: 'grape.errors.messages'), min: @min, max: @max
translate(:length, min: @min, max: @max)
elsif @min
format I18n.t(:length_min, scope: 'grape.errors.messages'), min: @min
translate(:length_min, min: @min)
elsif @max
format I18n.t(:length_max, scope: 'grape.errors.messages'), max: @max
translate(:length_max, max: @max)
else
format I18n.t(:length_is, scope: 'grape.errors.messages'), is: @is
translate(:length_is, is: @is)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/grape/validations/validators/same_as_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def build_message
if options_key?(:message)
@option[:message]
else
format I18n.t(:same_as, scope: 'grape.errors.messages'), parameter: @option
translate(:same_as, parameter: @option)
end
end
end
Expand Down
48 changes: 30 additions & 18 deletions spec/grape/exceptions/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,15 @@
let(:key) { :invalid_formatter }
let(:attributes) { { klass: String, to_format: 'xml' } }

after do
I18n.enforce_available_locales = true
I18n.available_locales = %i[en]
I18n.locale = :en
I18n.default_locale = :en
I18n.reload!
end
after { I18n.reload! }

context 'when I18n enforces available locales' do
before { I18n.enforce_available_locales = true }

context 'when the fallback locale is available' do
before do
around do |example|
I18n.available_locales = %i[de en]
I18n.default_locale = :de
I18n.with_locale(:de) { example.run }
ensure
I18n.available_locales = %i[en]
end

it 'returns the translated message' do
Expand All @@ -46,31 +40,49 @@
end

context 'when the fallback locale is not available' do
before do
around do |example|
I18n.available_locales = %i[de jp]
I18n.locale = :de
I18n.default_locale = :de
I18n.with_locale(:de) do
example.run
ensure
I18n.available_locales = %i[en]
end
end

it 'returns the translation string' do
it 'returns the scoped translation key as a string' do
expect(subject).to eq("grape.errors.messages.#{key}")
end
end
end

context 'when I18n does not enforce available locales' do
before { I18n.enforce_available_locales = false }
around do |example|
I18n.enforce_available_locales = false
example.run
ensure
I18n.enforce_available_locales = true
end

context 'when the fallback locale is available' do
before { I18n.available_locales = %i[de en] }
around do |example|
I18n.available_locales = %i[de en]
I18n.with_locale(:de) { example.run }
ensure
I18n.available_locales = %i[en]
end

it 'returns the translated message' do
expect(subject).to eq('cannot convert String to xml')
end
end

context 'when the fallback locale is not available' do
before { I18n.available_locales = %i[de jp] }
around do |example|
I18n.available_locales = %i[de jp]
I18n.with_locale(:de) { example.run }
ensure
I18n.available_locales = %i[en]
end

it 'returns the translated message' do
expect(subject).to eq('cannot convert String to xml')
Expand Down
Loading