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