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