From e619622d349f89f3f7656fca81216947b646eb3a Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 1 Aug 2026 14:15:53 +0200 Subject: [PATCH] Recompile the router when a route is declared after compilation The router is built once, when the API is first compiled, and #compile! memoizes the instance holding it. A route declared after that point was appended to the endpoint list -- .routes reported it -- but never reached the router, so requests for it answered 404 and recognize_path returned nil. The API's own metadata disagreed with what it served. Whether it bit depended on what had touched the API first: get('/one'); get('/two') # /two served api.routes; get('/two') # /two served Rack::Test.new(api).get('/one'); get('/two') # /two -> 404 api.recognize_path('/one'); get('/two') # /two -> 404 `helpers` and `mount` already call #change! for exactly this reason -- route declaration was the one mutating DSL call that did not. The dummy host in routing_spec gains #change!, which Grape::DSL::Routing already required of it: #mount calls it too, just from a branch that dummy never reaches. No boot cost: #change! is an ivar assignment, and compilation is lazy, so defining 2000 routes takes the same 1.24s either way. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/grape/dsl/routing.rb | 5 ++++ spec/grape/api_spec.rb | 42 ++++++++++++++++++++++++++++++++++ spec/grape/dsl/routing_spec.rb | 4 ++++ 4 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0049465b9..2c3e6d5da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ * [#2827](https://github.com/ruby-grape/grape/pull/2827): Make the `cascade` DSL getter return the configured value (`cascade false` read back as `true`) - [@ericproulx](https://github.com/ericproulx). * [#2829](https://github.com/ruby-grape/grape/pull/2829): Fix a cascading route handing over only to the last route registered for the path, making a middle version (3+ mounted versions with a catch-all) answer 406 - [@ericproulx](https://github.com/ericproulx). * [#2826](https://github.com/ruby-grape/grape/pull/2826): Fix `api.version` not being set for the root route of a path-versioned API (`GET /v1`) - [@ericproulx](https://github.com/ericproulx). +* [#2848](https://github.com/ruby-grape/grape/pull/2848): Recompile the router when a route is declared after the API has been compiled, so it is served instead of listed by `routes` and answering 404 - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 3.3.5 (2026-07-30) diff --git a/lib/grape/dsl/routing.rb b/lib/grape/dsl/routing.rb index b732d33c6..a38ceba33 100644 --- a/lib/grape/dsl/routing.rb +++ b/lib/grape/dsl/routing.rb @@ -206,6 +206,11 @@ def route(methods, paths = ['/'], requirements: nil, anchor: true, **route_optio ) endpoints << new_endpoint unless endpoints.include?(new_endpoint) + # The router is built once, when the API is first compiled, so an + # endpoint added after that would be listed by #routes and still 404. + # `helpers` and `mount` already invalidate for the same reason. + change! + inheritable_setting.route_end reset_validations! end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index ff3454f15..6d8830303 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -4120,6 +4120,48 @@ def my_method end end + # The router is built once, when the API is first compiled. A route declared + # after that was listed by .routes and still answered 404, so the API's own + # metadata disagreed with what it served. + describe 'a route declared after the API has been compiled' do + before do + subject.format :json + subject.get('/first') { 'first' } + end + + it 'is served after a request has already been made' do + get '/first' + subject.get('/second') { 'second' } + + get '/second' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('second'.to_json) + end + + it 'is served after recognize_path has compiled the router' do + subject.recognize_path('/first') + subject.get('/second') { 'second' } + + get '/second' + expect(last_response.status).to eq(200) + end + + it 'is recognised by recognize_path' do + get '/first' + subject.get('/second') { 'second' } + + expect(subject.recognize_path('/second')).not_to be_nil + end + + it 'leaves the routes declared before it serving' do + get '/first' + subject.get('/second') { 'second' } + + get '/first' + expect(last_response.body).to eq('first'.to_json) + end + end + describe '.endpoint' do before do subject.format :json diff --git a/spec/grape/dsl/routing_spec.rb b/spec/grape/dsl/routing_spec.rb index 9f5c2bf89..6e9f1e122 100644 --- a/spec/grape/dsl/routing_spec.rb +++ b/spec/grape/dsl/routing_spec.rb @@ -12,6 +12,10 @@ class << self attr_reader :instance, :base attr_accessor :configuration + + # Part of the contract Grape::DSL::Routing expects of its host, the + # same way #mount already relies on it. + def change!; end end end end