Skip to content
Closed
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 @@ -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
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions lib/grape.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions lib/grape/translator.rb
Original file line number Diff line number Diff line change
@@ -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
120 changes: 120 additions & 0 deletions lib/grape/translator/catalog.rb
Original file line number Diff line number Diff line change
@@ -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 <tt>%{name}</tt> into the
# <tt>%<name>s</tt> 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 <tt>%{name}</tt> and <tt>%<name>s</tt> 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
42 changes: 42 additions & 0 deletions lib/grape/translator/i18n.rb
Original file line number Diff line number Diff line change
@@ -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
36 changes: 7 additions & 29 deletions lib/grape/util/translation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading