diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c3612143..b4c0a9630 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
* [#2960](https://github.com/ruby-grape/grape/pull/2960): Lint every response of an API once under `lint!`, including the router's 404 and mounted Rack apps, instead of inside each endpoint's stack - [@ericproulx](https://github.com/ericproulx).
* [#2966](https://github.com/ruby-grape/grape/pull/2966): Require Ruby 3.3.1, the first 3.3 release that parses anonymous argument forwarding from inside a block - [@ericproulx](https://github.com/ericproulx).
* [#3008](https://github.com/ruby-grape/grape/pull/3008): Add the grape-on-rack app as a live-server memory profiling harness - [@ericproulx](https://github.com/ericproulx).
+* [#2980](https://github.com/ruby-grape/grape/pull/2980): Resolve error messages through `Grape.translator`, and add `Grape::Translator::Catalog`, which answers them from a table built once instead of through I18n - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.
#### Fixes
diff --git a/README.md b/README.md
index 062f9ad66..fe062e1d1 100644
--- a/README.md
+++ b/README.md
@@ -1999,6 +1999,34 @@ 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.
+#### Where messages come from
+
+`translate` hands every lookup to `Grape.translator`, which answers it. The default, `Grape::Translator::I18n`, asks I18n at request time — the behaviour described above, and what you want in an application that localizes.
+
+There are two because I18n cannot always be reached. It keeps its configuration in class variables — `@@backend`, `@@default_locale` — and a non-main Ractor may not read a class variable at all, frozen or not. An API served from a Ractor therefore cannot look a message up through I18n, and a message is what every validation failure needs.
+
+`Grape::Translator::Catalog` answers from a table built once instead:
+
+```ruby
+Grape.translator = Grape::Translator::Catalog.build
+```
+
+It reads Grape's own [en.yml](lib/grape/locale/en.yml) directly, so it needs no I18n at all, and merges whatever I18n has loaded over the top, so your overrides of `grape.errors.messages.*` and your other locales are carried. Looking a message up costs a `Hash` read and a `format` rather than a trip through I18n.
+
+What it trades away:
+
+* **It is a snapshot.** A locale file loaded lazily, or reloaded through `I18n.reload!` in development, is not in a table that was already built. Build it after your locales are loaded.
+* **The locale comes from `Grape.locale`**, not from I18n, because a non-main Ractor cannot read I18n's configuration at all. Set it per request where you would have set `I18n.locale`:
+
+```ruby
+before { Grape.locale = I18n.locale }
+```
+
+ It is stored per fiber, so requests do not see each other's, and a fiber started mid-request inherits it.
+* **It carries the `grape` scope only.** A validator translating from a namespace of its own has to name it: `Grape::Translator::Catalog.build(scopes: %w[grape my_app])`.
+
+A translator is anything answering `call(key, default:, scope:, locale:, **options)`, so an application can supply its own.
+
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
diff --git a/lib/grape.rb b/lib/grape.rb
index 200614f66..ab8e1d21a 100644
--- a/lib/grape.rb
+++ b/lib/grape.rb
@@ -95,6 +95,31 @@ module Grape
]
end.freeze
+ # Where an error message comes from (see Grape::Translator). The default
+ # asks I18n at request time, which is how Grape has always resolved them.
+ @translator = Translator::I18n
+
+ class << self
+ attr_accessor :translator
+ end
+
+ # The locale {Grape::Translator::Catalog} answers in. Fiber-local, so a
+ # +before+ filter can set it per request and fiber-based servers keep each
+ # request's locale to itself -- the same storage I18n uses for its own
+ # config, and Grape for its param scopes.
+ #
+ # The default translator reads I18n's locale instead, and ignores this.
+ LOCALE_KEY = :grape_locale
+ private_constant :LOCALE_KEY
+
+ def self.locale
+ Fiber[LOCALE_KEY]
+ end
+
+ def self.locale=(locale)
+ Fiber[LOCALE_KEY] = locale
+ end
+
# The deprecation horizon is the version a deprecation announces as its
# removal point, so it is the *next* major rather than the current one, and
# it is derived from VERSION rather than written out. Written out, it went
diff --git a/lib/grape/translator.rb b/lib/grape/translator.rb
new file mode 100644
index 000000000..9488bd7cb
--- /dev/null
+++ b/lib/grape/translator.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Grape
+ # Where Grape's error messages come from. {Grape::Util::Translation#translate}
+ # hands every lookup to +Grape.translator+, which answers it:
+ #
+ # * {Translator::I18n}, the default, asks I18n at request time, so a
+ # per-request locale and an application's own overrides both apply.
+ # * {Translator::Catalog} answers from a table built once, which is faster,
+ # needs no I18n at all, and can be read from a non-main Ractor.
+ #
+ # A translator is anything answering +call+ with this signature; the two
+ # here are the ones Grape ships.
+ module Translator
+ FALLBACK_LOCALE = :en
+
+ # Stands for "the caller named no default", so that nil and false can be
+ # defaults in their own right. Its +inspect+ names it in debug output.
+ MISSING = Class.new { def inspect = 'Grape::Translator::MISSING' }.new.freeze
+ end
+end
diff --git a/lib/grape/translator/catalog.rb b/lib/grape/translator/catalog.rb
new file mode 100644
index 000000000..76fe37bbd
--- /dev/null
+++ b/lib/grape/translator/catalog.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require 'yaml'
+
+module Grape
+ module Translator
+ # Every message Grape can answer with, in one frozen table built at boot:
+ #
+ # { en: { 'grape.errors.messages.presence' => 'is missing', ... }, ... }
+ #
+ # Flat keys, so a lookup is a single +Hash#[]+, and the interpolation
+ # placeholders are rewritten from I18n's %{name} into the
+ # %s that +format+ takes, so answering a message is a
+ # lookup and a +format+ rather than a trip through I18n.
+ #
+ # It reads Grape's own +locale/en.yml+ directly, so it works with I18n
+ # absent, and merges whatever I18n has loaded over the top, so an
+ # application's overrides and other locales are carried.
+ #
+ # What it trades away: the table is a snapshot. A locale file loaded after
+ # it was built -- lazily, or through +I18n.reload!+ in development -- is
+ # not in it. And the locale in force comes from {Grape.locale} rather than
+ # from I18n, which cannot be read from a non-main Ractor.
+ class Catalog
+ OWN_MESSAGES = File.expand_path('../locale/en.yml', __dir__)
+ private_constant :OWN_MESSAGES
+
+ # The top-level scopes carried over from I18n. A validator translating
+ # from a namespace of its own has to name it here.
+ DEFAULT_SCOPES = %w[grape].freeze
+
+ # +default_locale+ is the locale a lookup falls to when nothing named
+ # one, and defaults to I18n's at the moment the table is built.
+ def self.build(scopes: DEFAULT_SCOPES, default_locale: nil)
+ # A plain Hash, with no default block: the table is frozen at the end,
+ # and a miss must answer nil rather than try to write an empty locale
+ # into it.
+ messages = { FALLBACK_LOCALE => {} }
+ flatten_into(messages[FALLBACK_LOCALE], YAML.load_file(OWN_MESSAGES).fetch('en'), nil, scopes)
+ merge_i18n_into(messages, scopes) if defined?(::I18n)
+
+ new(Grape::Util::DeepFreeze.deep_freeze(messages), default_locale || i18n_default_locale)
+ end
+
+ class << self
+ private
+
+ def merge_i18n_into(messages, scopes)
+ ::I18n.backend.__send__(:init_translations) unless ::I18n.backend.initialized?
+ ::I18n.backend.translations.each do |locale, tree|
+ flatten_into(messages[locale.to_sym] ||= {}, tree, nil, scopes)
+ end
+ end
+
+ def i18n_default_locale
+ defined?(::I18n) ? ::I18n.default_locale : FALLBACK_LOCALE
+ end
+
+ # Walks a locale tree into dotted keys, keeping only the named
+ # top-level scopes. A leaf that is not a String -- a Proc in a
+ # pluralization rule, say -- is not a message and is left behind.
+ def flatten_into(target, tree, prefix, scopes)
+ tree.each do |key, value|
+ path = prefix ? "#{prefix}.#{key}" : key.to_s
+ next if prefix.nil? && !scopes.include?(path)
+
+ case value
+ when Hash then flatten_into(target, value, path, scopes)
+ when String then target[path] = interpolatable(value)
+ end
+ end
+ end
+
+ # I18n accepts %{name} and %s alike; +format+
+ # only knows the second, so the first is rewritten once, here, rather
+ # than on every message.
+ def interpolatable(message)
+ return message unless message.include?('%{')
+
+ message.gsub(/%\{(\w+)\}/) { "%<#{::Regexp.last_match(1)}>s" }
+ end
+ end
+
+ attr_reader :default_locale
+
+ def initialize(messages, default_locale)
+ @messages = messages
+ @default_locale = default_locale
+ freeze
+ end
+
+ def call(key, default: MISSING, scope: 'grape.errors.messages', locale: nil, **options)
+ path = "#{scope}.#{key}"
+ message = lookup(path, locale || Grape.locale || @default_locale)
+ return interpolate(message, options) if message
+ return path if default.equal?(MISSING)
+
+ interpolate(default, options)
+ end
+
+ # The locales the table carries, for an application that wants to check
+ # what a built catalog knows.
+ def locales
+ @messages.keys
+ end
+
+ private
+
+ def lookup(path, locale)
+ @messages.dig(locale, path) || @messages.dig(FALLBACK_LOCALE, path)
+ end
+
+ def interpolate(message, options)
+ return message unless message.is_a?(String) && options.any? && message.include?('%<')
+
+ format(message, **options)
+ end
+ end
+ end
+end
diff --git a/lib/grape/translator/i18n.rb b/lib/grape/translator/i18n.rb
new file mode 100644
index 000000000..ad0fa05c7
--- /dev/null
+++ b/lib/grape/translator/i18n.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Grape
+ module Translator
+ # Resolves through the i18n gem, as Grape always has: at request time, so
+ # that the locale in force and any translation the application has loaded
+ # over Grape's own both apply.
+ #
+ # NOTE: every reference to the gem below is spelled +::I18n+. A bare +I18n+
+ # inside this module finds this one.
+ module I18n
+ class << self
+ # Extra keyword args are forwarded verbatim to I18n as interpolation
+ # variables (e.g. +min:+, +max:+ from LengthValidator's Hash message),
+ # so a caller must not pass unintended keywords.
+ def call(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)
+
+ # Only a call without +default:+ gets here: I18n answers any other with
+ # that default, so a miss falls back to the key's full path instead.
+ scoped_key = [*Array(scope), key].join('.')
+ return scoped_key if fallback_locale?(locale) || fallback_locale_unavailable?
+
+ ::I18n.translate(key, default: scoped_key, scope:, locale: FALLBACK_LOCALE, **)
+ end
+
+ private
+
+ 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
+end
diff --git a/lib/grape/util/translation.rb b/lib/grape/util/translation.rb
index 9a69920b8..d2c791ea6 100644
--- a/lib/grape/util/translation.rb
+++ b/lib/grape/util/translation.rb
@@ -2,40 +2,18 @@
module Grape
module Util
+ # The message lookup every exception and validator reaches for. What
+ # answers it is +Grape.translator+ (see Grape::Translator), so an
+ # application can swap I18n for a table without any of them changing.
module Translation
- FALLBACK_LOCALE = :en
- private_constant :FALLBACK_LOCALE
- # Sentinel returned by I18n when a key is missing (passed as the default:
- # value). Its +inspect+ names it in debug output.
- 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
+ # Extra keyword args (**) are forwarded verbatim 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)
-
- # Only a call without +default:+ gets here: I18n answers any other with
- # that default, so a miss falls back to the key's full path instead.
- scoped_key = [*Array(scope), key].join('.')
- return scoped_key if fallback_locale?(locale) || fallback_locale_unavailable?
-
- ::I18n.translate(key, default: scoped_key, 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)
+ # will silently become an interpolation variable.
+ def translate(key, default: Grape::Translator::MISSING, scope: 'grape.errors.messages', locale: nil, **)
+ Grape.translator.call(key, default:, scope:, locale:, **)
end
end
end
diff --git a/spec/grape/translator/catalog_spec.rb b/spec/grape/translator/catalog_spec.rb
new file mode 100644
index 000000000..0c09c0d96
--- /dev/null
+++ b/spec/grape/translator/catalog_spec.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+describe Grape::Translator::Catalog do
+ subject(:catalog) { described_class.build }
+
+ describe '.build' do
+ it 'reads Grape\'s own messages without asking I18n for them' do
+ expect(catalog.call(:presence)).to eq('is missing')
+ end
+
+ it 'carries the locales I18n has loaded' do
+ expect(catalog.locales).to include(:en)
+ end
+
+ it 'answers a frozen, shareable table' do
+ expect(Ractor.shareable?(catalog)).to be true
+ end
+
+ it 'takes the default locale from I18n' do
+ expect(catalog.default_locale).to eq(I18n.default_locale)
+ end
+
+ it 'carries only the scopes it was asked for' do
+ catalog = described_class.build(scopes: %w[nothing_of_ours])
+ expect(catalog.call(:presence)).to eq('grape.errors.messages.presence')
+ end
+ end
+
+ describe '#call' do
+ it 'interpolates' do
+ expect(catalog.call(:length_min, min: 3)).to eq('is expected to have length greater than or equal to 3')
+ end
+
+ it 'reads a nested key' do
+ expect(catalog.call(:'invalid_accept_header.problem')).to eq('invalid accept header')
+ end
+
+ it 'reads another scope' do
+ expect(catalog.call(:format, scope: 'grape.errors', attributes: 'name', message: 'is missing')).to eq('name is missing')
+ end
+
+ it 'answers the key path when nothing is found and no default was named' do
+ expect(catalog.call(:no_such_message)).to eq('grape.errors.messages.no_such_message')
+ end
+
+ it 'answers the default when one was named, interpolating it' do
+ expect(catalog.call(:no_such_message, default: 'fallback %s', value: 7)).to eq('fallback 7')
+ end
+ end
+
+ describe 'the locale in force' do
+ around do |example|
+ # Two traps: storing before the backend has read its load path is undone
+ # by it, and a store for a locale outside available_locales is dropped
+ # without a word when enforcement is on.
+ I18n.backend.__send__(:init_translations) unless I18n.backend.initialized?
+ available = I18n.available_locales_initialized? ? I18n.available_locales : nil
+ I18n.available_locales = (available || I18n.available_locales) | %i[es]
+ I18n.backend.store_translations(:es, grape: { errors: { messages: { presence: 'falta' } } })
+ example.run
+ ensure
+ Grape.locale = nil
+ I18n.available_locales = available
+ I18n.backend.reload!
+ end
+
+ it 'answers in the locale Grape.locale names' do
+ Grape.locale = :es
+ expect(catalog.call(:presence)).to eq('falta')
+ end
+
+ it 'falls back to English for a locale it does not carry' do
+ Grape.locale = :de
+ expect(catalog.call(:presence)).to eq('is missing')
+ end
+
+ it 'answers in the locale the caller names, over Grape.locale' do
+ Grape.locale = :es
+ expect(catalog.call(:presence, locale: :en)).to eq('is missing')
+ end
+
+ # Fiber storage is inherited by a fiber's children, which is what a
+ # request wants, but a write inside one stays there.
+ it 'keeps a locale set inside a fiber from leaking back out of it' do
+ Grape.locale = :es
+ Fiber.new { Grape.locale = :en }.resume
+ expect(Grape.locale).to eq(:es)
+ end
+ end
+
+ # The two translators answer the same lookups, so that swapping one for the
+ # other cannot change a message. Every key Grape ships is compared, with a
+ # value supplied for each placeholder the message carries.
+ describe 'agreeing with the I18n translator' do
+ def each_message(tree, prefix = 'grape', &block)
+ tree.each do |key, value|
+ path = "#{prefix}.#{key}"
+ value.is_a?(Hash) ? each_message(value, path, &block) : yield(path, value)
+ end
+ end
+
+ it 'answers every message the same way' do
+ messages = YAML.load_file(File.expand_path('../../../lib/grape/locale/en.yml', __dir__)).dig('en', 'grape')
+ compared = 0
+
+ each_message(messages) do |path, template|
+ scope, _, key = path.rpartition('.')
+ options = template.scan(/%\{(\w+)\}/).flatten.to_h { |name| [name.to_sym, "<#{name}>"] }
+
+ expect(catalog.call(key, scope:, **options))
+ .to eq(Grape::Translator::I18n.call(key, scope:, **options)), "#{path} differs"
+ compared += 1
+ end
+
+ expect(compared).to be > 30
+ end
+ end
+end
diff --git a/spec/grape/translator_spec.rb b/spec/grape/translator_spec.rb
new file mode 100644
index 000000000..a3c702acf
--- /dev/null
+++ b/spec/grape/translator_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+describe Grape::Translator do
+ subject { Class.new(Grape::API) }
+
+ let(:app) { subject }
+
+ before do
+ subject.format :json
+ subject.params { requires :name, type: String, length: { min: 3 } }
+ subject.get('/hello') { { hello: params[:name] } }
+ end
+
+ describe 'Grape.translator' do
+ it 'resolves through I18n out of the box' do
+ expect(Grape.translator).to eq(Grape::Translator::I18n)
+ end
+ end
+
+ describe 'an error response' do
+ let(:i18n_body) do
+ get '/hello', name: 'ab'
+ last_response.body
+ end
+
+ around do |example|
+ default = Grape.translator
+ example.run
+ ensure
+ Grape.translator = default
+ end
+
+ it 'reads the same with either translator' do
+ expected = i18n_body
+ Grape.translator = Grape::Translator::Catalog.build
+
+ get '/hello', name: 'ab'
+ expect(last_response.body).to eq(expected)
+ expect(last_response.status).to eq(400)
+ end
+
+ it 'carries an application\'s own override of a Grape message' do
+ # Storing before the backend has read its load path is undone by it.
+ I18n.backend.__send__(:init_translations) unless I18n.backend.initialized?
+ I18n.backend.store_translations(:en, grape: { errors: { messages: { length_min: 'is too short, %s at least' } } })
+ Grape.translator = Grape::Translator::Catalog.build
+
+ get '/hello', name: 'ab'
+ expect(last_response.body).to include('is too short, 3 at least')
+ ensure
+ I18n.backend.reload!
+ end
+ end
+end