diff --git a/CHANGELOG.md b/CHANGELOG.md index 291361cd9..042bf7296 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). +* [#3022](https://github.com/ruby-grape/grape/pull/3022): Compile the APIs mounted in a Rails application's routes at boot when it eager loads - [@ericproulx](https://github.com/ericproulx). * Your contribution here. #### Fixes diff --git a/README.md b/README.md index 990c93dc9..be74036f7 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ By default Grape will compile the routes on the first request, but it is possibl 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. +This can be added to your `config.ru` (if using rackup) or any file that loads your server. In Rails, the APIs mounted in the routes are compiled for you, see [Rails](#rails). ### Rack @@ -197,6 +197,15 @@ Modify `config/routes`: ```ruby mount Twitter::API => '/' ``` + +Whenever `config.eager_load` is on, as it is in production, Rails compiles the APIs mounted in its routes at boot, including those an engine's routes mount. An API that reaches Rails another way, such as inside a `Rack::Builder`, is compiled on its first request. To compile it at boot, call `compile!` once the application has initialized, since the API cannot be autoloaded yet in `config/application.rb` or an initializer: + +```ruby +config.after_initialize do + Twitter::API.compile! +end +``` + #### Zeitwerk Rails's default autoloader is `Zeitwerk`. By default, it inflects `api` as `Api` instead of `API`. To make our example work, you need to uncomment the lines at the bottom of `config/initializers/inflections.rb`, and add `API` as an acronym: diff --git a/UPGRADING.md b/UPGRADING.md index e1107367b..5148aa0f1 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -3,6 +3,12 @@ Upgrading Grape ### Upgrading to >= 4.1.0 +#### APIs mounted in a Rails application's routes are compiled at boot + +When `config.eager_load` is on, as it is in production, Grape's Railtie now compiles the APIs mounted in the application's routes, and in the routes of the engines it mounts, as soon as the routes are loaded, rather than leaving it to the first request each process answers ([#3022](https://github.com/ruby-grape/grape/pull/3022)). A `compile!` call the application already makes can stay: an API is compiled once. + +Compiling builds each endpoint's middleware stack, so a middleware's `initialize` now runs at boot, and before the workers fork under a server that preloads the application, such as Puma with `preload_app!`. A middleware that opens a connection or starts a thread in `initialize` should do it on its first request instead. An API that fails to compile now fails the boot rather than its first request. + #### `logger` applies to the API it is set on `logger` kept the logger in settings shared by the whole process, so setting it on one API replaced it on every other: two APIs that each set a logger both logged to whichever was set last. diff --git a/lib/grape/railtie.rb b/lib/grape/railtie.rb index 42eb3442c..e4e93abd5 100644 --- a/lib/grape/railtie.rb +++ b/lib/grape/railtie.rb @@ -5,5 +5,26 @@ class Railtie < ::Rails::Railtie initializer 'grape.deprecator' do |app| app.deprecators[:grape] = Grape.deprecator end + + # Rails builds its own routes' router at boot when it eager loads; compile + # the APIs mounted in them then too, rather than on the first request each + # process answers. + config.after_routes_loaded do |app| + compile_mounted_apis(app.routes) if app.config.eager_load + end + + class << self + private + + def compile_mounted_apis(route_set) + route_set.routes.each do |route| + endpoint = route.app + next compile_mounted_apis(endpoint.rack_app.routes) if endpoint.engine? + + rack_app = endpoint.rack_app + rack_app.compile! if rack_app.is_a?(Class) && rack_app < Grape::API + end + end + end end end diff --git a/spec/integration/rails/railtie_spec.rb b/spec/integration/rails/railtie_spec.rb index d77cc0794..d99a52271 100644 --- a/spec/integration/rails/railtie_spec.rb +++ b/spec/integration/rails/railtie_spec.rb @@ -23,5 +23,84 @@ it { is_expected.to be(Grape.deprecator) } end + + describe 'compiling the APIs mounted in the routes' do + let(:api) { Class.new(Grape::API) { get('/ping') { 'pong' } } } + let(:mounted) { api } + let(:eager_load) { true } + + let(:test_app) do + require 'action_controller/railtie' + # Every application shares config.action_view, and load_defaults below + # writes settings back into it that ActionView::Base has no setter for. + # Loading ActionView::Base first runs the :action_view hooks an earlier + # example's application left pending, before they can read them; + # eager loading would otherwise load it in the middle of the boot. + require 'action_view/base' + + # https://github.com/rails/rails/issues/51784 + # same error as described if not redefining the following + ActiveSupport::Dependencies.autoload_paths = [] + ActiveSupport::Dependencies.autoload_once_paths = [] + + eager = eager_load + app = mounted + Class.new(Rails::Application) do + config.eager_load = eager + config.load_defaults "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}" + routes.append { mount app => '/' } + end + end + + # Eager loading calls I18n.eager_load!, after which the backend reloads its + # translations at once on every I18n.reload!, under whatever + # available_locales a later example has set, instead of on the next lookup. + around do |example| + backend = I18n.backend + I18n.backend = I18n::Backend::Simple.new + example.run + ensure + I18n.backend = backend + end + + before do + stub_const('GrapeApi', api) + # Rails 8 hands after_routes_loaded Rails.application, which is memoized + # to the first application this process initialized. + allow(Rails).to receive(:application).and_return(test_app.instance) + end + + context 'when Rails eager loads' do + it 'compiles them at boot' do + expect(api).to receive(:compile!).and_call_original + test_app.initialize! + end + end + + context 'when Rails does not eager load' do + let(:eager_load) { false } + + it 'leaves them to the first request' do + expect(api).not_to receive(:compile!) + test_app.initialize! + end + end + + context 'when an engine mounts the API' do + let(:engine) { Class.new(Rails::Engine) } + let(:mounted) { engine } + + before do + stub_const('GrapeEngine', engine) + mounted_api = api + engine.routes.append { mount mounted_api => '/' } + end + + it 'compiles it at boot' do + expect(api).to receive(:compile!).and_call_original + test_app.initialize! + end + end + end end end