Skip to content
Merged
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 @@ -2,6 +2,7 @@

#### Features

* [#3019](https://github.com/ruby-grape/grape/pull/3019): Add `API.eager_load!` to compile an API and load its I18n translations at boot - [@ericproulx](https://github.com/ericproulx).
* [#3052](https://github.com/ruby-grape/grape/pull/3052): Add `Grape::ErrorFormatter::ProblemJson` to answer errors as RFC 9457 problem details - [@ericproulx](https://github.com/ericproulx).
* [#3056](https://github.com/ruby-grape/grape/pull/3056): Drop support for Rails 7.2 and require ActiveSupport 8.0 - [@ericproulx](https://github.com/ericproulx).
* [#3062](https://github.com/ruby-grape/grape/pull/3062): Use the reusable Danger workflow with a separate Gemfile - [@dblock](https://github.com/dblock).
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

To also load I18n's translations at boot, which the first error response would otherwise read, call `eager_load!` instead. See [I18n](#i18n).

### Rack

The above sample creates a Rack application that can be run from a rackup `config.ru` file with `rackup`:
Expand Down Expand Up @@ -2000,6 +2002,14 @@ 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. Elsewhere, call `eager_load!` at boot, which compiles the API and loads them:

```ruby
Twitter::API.eager_load!
```

Call `eager_load!` 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]`) drops the loaded translations, which the next lookup reads again.

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
2 changes: 1 addition & 1 deletion benchmark/grape_on_rack/config.ru
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ Dir[File.expand_path('api/*.rb', __dir__)].each { |file| require file }
require_relative 'app/api'
require_relative 'app/acme_app'

Acme::API.compile!
Acme::API.eager_load!

run Acme::App.instance
4 changes: 2 additions & 2 deletions lib/grape/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class API
# protected methods too, so it has to be named here for {.override_all_methods!}
# to leave it alone. +base+ is read on the API class itself whenever a mount
# is refreshed, so recording it would refresh every mount below it again.
NON_OVERRIDABLE = %i[base base= base_instance? call change! configuration compile! inherit_settings recognize_path reset! routes top_level_setting].freeze
NON_OVERRIDABLE = %i[base base= base_instance? call change! configuration compile! eager_load! inherit_settings recognize_path reset! routes top_level_setting].freeze

# DSL methods that answer a setting when called with nothing to set -- no
# argument, keyword or block -- and change none. Such a call is a read, and
Expand Down Expand Up @@ -46,7 +46,7 @@ class << self
# +inherit_settings+ is left out: it is protected on the base instance, so
# a delegator would raise NoMethodError just as the missing one does.
# +call+ is written out below.
def_delegators :base_instance, :new, :configuration, :change!, :compile!, :recognize_path, :routes,
def_delegators :base_instance, :new, :configuration, :change!, :compile!, :eager_load!, :recognize_path, :routes,
:base, :base=, :base_instance?, :reset!, :top_level_setting

# The interface point between Rack and Grape; it accepts a request from
Expand Down
23 changes: 23 additions & 0 deletions lib/grape/api/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ def compile!
@instance || LOCK.synchronize { @instance ||= new }
end

# Compiles the API and loads I18n's translations, which its first
# request and its first error response would otherwise do. Meant for
# boot, once the application's locale files are on +I18n.load_path+.
#
# @example
# Twitter::API.eager_load!
def eager_load!
load_i18n_translations
compile!
end

# see Grape::Router#recognize_path
def recognize_path(path)
compile!.router.recognize_path(path)
Expand Down Expand Up @@ -97,6 +108,18 @@ def inherited(subclass)
super
subclass.reset!
end

# A Simple backend is loaded the way its first lookup loads it: its
# +eager_load!+ would also make every later +I18n.reload!+ load at
# once, and raises on a LazyLoadable backend set to +lazy_load+. Any
# other backend is eager loaded if it can be, or left to its first
# lookup.
def load_i18n_translations
backend = I18n.backend
return backend.translations(do_init: true) if backend.is_a?(I18n::Backend::Simple::Implementation)

backend.eager_load! if backend.respond_to?(:eager_load!)
end
end

attr_reader :router
Expand Down
100 changes: 100 additions & 0 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,106 @@

let(:app) { subject }

describe '.eager_load!' do
around do |example|
backend = I18n.backend
I18n.backend = I18n::Backend::Simple.new
example.run
ensure
I18n.backend = backend
end

it 'compiles the API that serves its requests' do
subject.get('/x') { 'x' }

compiled = subject.eager_load!

expect(subject.compile!).to be(compiled)
end

# Mounting replays the app's setup, so a recorded call would run again.
it 'does not add to the app setup' do
subject.get('/x') { 'x' }
subject.eager_load!
I18n.reload!

Class.new(described_class).mount(subject)

expect(I18n.backend).not_to be_initialized
end

it 'reads the translations rather than leaving them to the first error' do
subject.get('/x') { 'x' }

subject.eager_load!

expect(I18n.backend).to be_initialized
end

it 'is left out of compiling, so a first request that succeeds reads no translations' do
subject.get('/x') { 'x' }

get '/x'

expect(last_response.status).to eq 200
expect(I18n.backend).not_to be_initialized
end

it 'leaves the translations dropped by a later reload to the next lookup' do
subject.get('/x') { 'x' }
subject.eager_load!

I18n.reload!

expect(I18n.backend).not_to be_initialized
end

it 'reads the current locale of a backend that loads one locale at a time' do
I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: true)
subject.get('/x') { 'x' }

subject.eager_load!

expect(I18n.backend).to be_initialized
end

it 'eager loads a backend that is not a Simple one' do
I18n.backend = I18n::Backend::Chain.new(I18n::Backend::Simple.new)
subject.get('/x') { 'x' }

subject.eager_load!

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' }
subject.eager_load!

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' }
subject.eager_load!

get '/x'

expect(last_response.status).to eq 400
expect(last_response.body).to eq 'id is missing'
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
Expand Down
Loading