From 5ce1cc27eea27e3454ef7911507e94b6d6f8d12a Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Tue, 29 Sep 2026 07:14:15 +0200 Subject: [PATCH] Add a setting to load I18n translations when an API is compiled I18n reads its locale files on the first lookup, so in an app that precompiles its API the first error a process answered still paid for the load: 3.65 ms against 0.87 ms once the translations were in memory. Grape.config.eager_load_i18n makes compiling eager-load the I18n backend, so an app calling compile! at boot pays it there. It is off by default. Rails, where Grape is most often mounted, already eager-loads I18n when config.eager_load is on, and keeps it lazy otherwise; forcing it there would make every I18n.reload! load at once in development, and could read the translations before the app's initializers have set its load path. The backend is asked directly, as one that does not inherit I18n::Backend::Base may have no eager_load!. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 15 ++++++++ benchmark/grape_on_rack/config.ru | 4 +++ lib/grape.rb | 5 +++ lib/grape/api/instance.rb | 10 ++++++ spec/grape/api_spec.rb | 60 +++++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 034d240cd..2dbc7bf58 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). +* [#3019](https://github.com/ruby-grape/grape/pull/3019): Add the `eager_load_i18n` setting to load I18n translations when an API is compiled - [@ericproulx](https://github.com/ericproulx). * [#3025](https://github.com/ruby-grape/grape/pull/3025): Add `:md` and `:markdown` formatters that render an object's `to_markdown`, for APIs that declare a `text/markdown` content type - [@ericproulx](https://github.com/ericproulx). * [#3029](https://github.com/ruby-grape/grape/pull/3029): Add a built-in `:jsonl` (JSON Lines) format that streams an Enumerator body - [@ericproulx](https://github.com/ericproulx). * Your contribution here. diff --git a/README.md b/README.md index 8723ca5e4..8bff394cd 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ Twitter::API.compile! This can be added to your `config.ru` (if using rackup), `application.rb` (if using rails), or any file that loads your server. +Outside Rails, compiling can also load I18n's translations. See [I18n](#i18n). + ### Rack The above sample creates a Rack application that can be run from a rackup `config.ru` file with `rackup`: @@ -582,6 +584,7 @@ Currently the configurable settings are: * `param_builder`: Sets the [Parameter Builder](#parameters), defaults to `:hash_with_indifferent_access`. * `lint`: Checks every response against Rack's specification, defaults to `false`. See [Linting](#linting). * `warn_on_helper_overrides`: Warns when a helper method masks a `Grape::Endpoint` instance method, defaults to `false`. +* `eager_load_i18n`: Loads I18n's translations when an API is compiled rather than on the first lookup, usually the first error response, defaults to `false`. Rails already does this when `config.eager_load` is on. See [I18n](#i18n). * `raise_rendering_errors`: Lets an error response that cannot be rendered propagate out of the middleware stack instead of being answered with a failsafe `500`, defaults to `false`. See [When the error response itself cannot be rendered](#when-the-error-response-itself-cannot-be-rendered). To change a setting value make sure that at some point during load time the following code runs @@ -1999,6 +2002,18 @@ 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. +I18n reads its translations on the first lookup, which is usually the first error response a process answers. In Rails, the application loads them at boot whenever `config.eager_load` is on, as it is in production, so there is nothing to set. Elsewhere, turn on `eager_load_i18n` to have compiling an API load them, and compile at boot: + +```ruby +Grape.configure do |config| + config.eager_load_i18n = true +end + +Twitter::API.compile! +``` + +Call `compile!` after your own locale files are on `I18n.load_path`: a file appended later (`I18n.load_path << path`) is read only after `I18n.reload!`, while assigning the path (`I18n.load_path += [path]`) reloads the translations at once. + 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/benchmark/grape_on_rack/config.ru b/benchmark/grape_on_rack/config.ru index d39ed387b..a37b267cb 100644 --- a/benchmark/grape_on_rack/config.ru +++ b/benchmark/grape_on_rack/config.ru @@ -11,6 +11,10 @@ Dir[File.expand_path('api/*.rb', __dir__)].each { |file| require file } require_relative 'app/api' require_relative 'app/acme_app' +Grape.configure do |config| + config.eager_load_i18n = true +end + Acme::API.compile! run Acme::App.instance diff --git a/lib/grape.rb b/lib/grape.rb index 200614f66..0eaab456b 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -61,6 +61,11 @@ module Grape # Let an error response that cannot be rendered propagate out of the # middleware stack instead of being answered with a failsafe 500. setting :raise_rendering_errors, default: false + # Load I18n's translations when an API is compiled instead of on the first + # lookup, usually the first error response. Off by default: Rails, where + # Grape is most often mounted, loads them itself when config.eager_load is + # on and keeps them lazy otherwise. + setting :eager_load_i18n, default: false # The HTTP QUERY method (RFC 10008): a safe, idempotent request whose content # carries the query. Rack has no constant for it yet, hence the literal. diff --git a/lib/grape/api/instance.rb b/lib/grape/api/instance.rb index 1c84209d2..74628372d 100644 --- a/lib/grape/api/instance.rb +++ b/lib/grape/api/instance.rb @@ -104,6 +104,7 @@ def inherited(subclass) # Builds the routes from the defined endpoints, effectively compiling # this API into a usable form. def initialize + eager_load_i18n if Grape.config[:eager_load_i18n] @router = Router.new add_head_not_allowed_methods_and_options_methods self.class.endpoints.each do |endpoint| @@ -157,6 +158,15 @@ def cascade? private + # I18n reads its files on the first lookup, which would be the first + # error a process answers. A backend that does not inherit + # I18n::Backend::Base, or one from an older i18n, may have no + # +eager_load!+, and is left to load on that first lookup. + def eager_load_i18n + backend = I18n.backend + backend.eager_load! if backend.respond_to?(:eager_load!) + end + # For every resource add a 'OPTIONS' route that returns an HTTP 204 response # with a list of HTTP methods that can be called. Also add a route that # will return an HTTP 405 response for any HTTP method that the resource diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 16ca087de..b6b36e3c5 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -7,6 +7,66 @@ let(:app) { subject } + describe 'loading translations' do + around do |example| + backend = I18n.backend + I18n.backend = I18n::Backend::Simple.new + example.run + ensure + I18n.backend = backend + end + + it 'leaves them to the first lookup by default' do + subject.get('/x') { 'x' } + subject.compile! + + expect(I18n.backend).not_to be_initialized + end + + context 'when Grape.config.eager_load_i18n is on' do + around do |example| + Grape.config.eager_load_i18n = true + example.run + ensure + Grape.config.eager_load_i18n = false + end + + it 'reads them when the API is compiled rather than on the first error' do + subject.get('/x') { 'x' } + expect(I18n.backend).not_to be_initialized + + subject.compile! + + expect(I18n.backend).to be_initialized + end + + it 'still answers the first error in the message it is translated to' do + subject.params { requires :id } + subject.get('/x') { 'x' } + + get '/x' + + expect(last_response.status).to eq 400 + expect(last_response.body).to eq 'id is missing' + end + + it 'answers through a backend that cannot load eagerly' do + simple = I18n.backend + I18n.backend = Class.new do + define_method(:translate) { |*args, **options| simple.translate(*args, **options) } + define_method(:available_locales) { simple.available_locales } + end.new + subject.params { requires :id } + subject.get('/x') { 'x' } + + get '/x' + + expect(last_response.status).to eq 400 + expect(last_response.body).to eq 'id is missing' + end + end + end + describe 'thread-safe (re)compilation' do # Regression: building the HEAD/OPTIONS/405 helper routes used to delete the # root-prefix/versioning keys from the shared class-level settings and