From 5e0b92ad3cfda33f3977965d4e64b18082fe8ec0 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Tue, 22 Sep 2026 12:27:56 +0200 Subject: [PATCH] Resolve error messages through a translator Grape::Util::Translation called I18n directly, so every error message was an I18n lookup and nothing else was possible. It now hands the lookup to Grape.translator, and ships two that answer it. Grape::Translator::I18n is the default and is today's code moved, not changed: it resolves at request time, so the locale in force and an application's own overrides both apply. Grape::Translator::Catalog answers from one frozen table instead, built once from Grape's own locale/en.yml -- read with YAML, so it needs no I18n -- with whatever I18n has loaded merged over it, so overrides and other locales are carried. Keys are flat and the interpolation placeholders are rewritten into the form format takes, so a lookup is a Hash read and a format. A 400 validation-error response measures 32.2 us against 53.4 us, 1.66x faster. What the catalog trades away is in the README: it is a snapshot, so a locale loaded after it was built is not in it; the locale in force comes from Grape.locale, which is fiber-local, rather than from I18n; and it carries the scopes it was asked for, grape by default. The two are held to each other by a spec that compares every message Grape ships, with a value supplied for each placeholder, so the one cannot drift from the other. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + README.md | 28 ++++++ lib/grape.rb | 25 ++++++ lib/grape/translator.rb | 21 +++++ lib/grape/translator/catalog.rb | 120 ++++++++++++++++++++++++++ lib/grape/translator/i18n.rb | 42 +++++++++ lib/grape/util/translation.rb | 36 ++------ spec/grape/translator/catalog_spec.rb | 118 +++++++++++++++++++++++++ spec/grape/translator_spec.rb | 54 ++++++++++++ 9 files changed, 416 insertions(+), 29 deletions(-) create mode 100644 lib/grape/translator.rb create mode 100644 lib/grape/translator/catalog.rb create mode 100644 lib/grape/translator/i18n.rb create mode 100644 spec/grape/translator/catalog_spec.rb create mode 100644 spec/grape/translator_spec.rb 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