From 7ed6917c4366b1c2d7711fc6c53b84827c3321cf Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Mon, 26 Oct 2026 22:37:16 -0400 Subject: [PATCH] Support request-aware template roots Resolve configured roots in the current endpoint context, preserve explicit request roots, and isolate cached templates and layouts by resolved path. Keep static roots and unversioned paths unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 15 +++++ lib/grape-rabl/configuration.rb | 3 +- lib/grape-rabl/formatter.rb | 6 +- spec/grape_rabl_formatter_spec.rb | 95 +++++++++++++++++++++++++++++++ spec/grape_rabl_spec.rb | 2 +- 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e263de..ab40b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Your contribution here. +* [#58](https://github.com/ruby-grape/grape-rabl/pull/58): Support static and request-aware configured template roots while preserving explicit request roots and cache isolation - [@dblock](https://github.com/dblock). * [#57](https://github.com/ruby-grape/grape-rabl/pull/57): Remove the obsolete Code Climate badge, use HTTPS and RubyGems for the gem badge, and scope the CI badge to master pushes - [@dblock](https://github.com/dblock). * [#56](https://github.com/ruby-grape/grape-rabl/pull/56): Merge Coveralls coverage across the stable Ruby/Grape matrix and refresh the coverage badge - [@dblock](https://github.com/dblock). * [#55](https://github.com/ruby-grape/grape-rabl/pull/55): Replace deprecated `Tilt::Cache` with a synchronized Hash cache for templates and layouts - [@dblock](https://github.com/dblock). diff --git a/README.md b/README.md index 23aba55..7a8e172 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Use [Rabl](https://github.com/nesquena/rabl) templates in [Grape](https://github - [Upgrading](#upgrading) - [Usage](#usage) - [Setup view root directory](#setup-view-root-directory) + - [Configure request-aware view roots](#configure-request-aware-view-roots) - [Tell your API to use Grape::Formatter::Rabl](#tell-your-api-to-use-grapeformatterrabl) - [Use rabl templates conditionally](#use-rabl-templates-conditionally) - [Use rabl layout](#use-rabl-layout) @@ -52,6 +53,20 @@ use Rack::Config do |env| end ``` +### Configure request-aware view roots + +Instead of setting a root with middleware, configure a static directory or a zero-argument Proc/lambda: + +```ruby +Grape::Rabl.configure do |config| + config.tilt_root = -> { File.join('/path/to/views', env['api.version'].to_s) } +end +``` + +The Proc runs in the current Grape endpoint's context, so it can access `env`, `params`, and API helpers. The example uses `/path/to/views/v1` for version `v1` and `/path/to/views` for an unversioned request. Version directories are opt-in: a static `config.tilt_root` or existing `env['api.tilt.root']` is used unchanged. + +An explicit `env['api.tilt.root']` takes precedence over the configured root, and the Proc is not called in that case. Resolution happens only when rendering a Rabl template; the resolved root is stored in the request environment, not back into configuration. Templates, partials, and layouts use that root, and cached templates remain separated by resolved directory and format. Resolver exceptions propagate; returning `nil` or `false` raises the missing-root configuration error. + ### Tell your API to use Grape::Formatter::Rabl ```ruby diff --git a/lib/grape-rabl/configuration.rb b/lib/grape-rabl/configuration.rb index 1acb5d3..c3758d2 100644 --- a/lib/grape-rabl/configuration.rb +++ b/lib/grape-rabl/configuration.rb @@ -3,10 +3,11 @@ module Grape module Rabl class Configuration - attr_accessor :cache_template_loading + attr_accessor :cache_template_loading, :tilt_root def initialize @cache_template_loading = false + @tilt_root = nil end end end diff --git a/lib/grape-rabl/formatter.rb b/lib/grape-rabl/formatter.rb index cafcbd9..1904d40 100644 --- a/lib/grape-rabl/formatter.rb +++ b/lib/grape-rabl/formatter.rb @@ -78,7 +78,11 @@ def rabl_template end def set_view_root - raise "Use Rack::Config to set 'api.tilt.root' in config.ru" + root = Grape::Rabl.configuration.tilt_root + env['api.tilt.root'] = root.is_a?(Proc) ? endpoint.instance_exec(&root) : root + return if env['api.tilt.root'] + + raise "Use Rack::Config to set 'api.tilt.root' in config.ru or configure 'tilt_root'" end def tilt_template(template) diff --git a/spec/grape_rabl_formatter_spec.rb b/spec/grape_rabl_formatter_spec.rb index 8a95f2c..e807df0 100644 --- a/spec/grape_rabl_formatter_spec.rb +++ b/spec/grape_rabl_formatter_spec.rb @@ -39,6 +39,101 @@ end end +describe 'configured template roots' do + let(:app) { Class.new(Grape::API) } + let(:request_root) { nil } + let(:root) do + lambda { + File.join(__dir__, 'views', env['api.version'] == 'v2' ? 'layout_test' : '') + } + end + + before do + Grape::Rabl.configuration.tilt_root = root + Grape::Rabl::Formatter.tilt_cache.clear + app.version %w[v1 v2], using: :path + app.format :json + app.formatter :json, Grape::Formatter::Rabl + override = request_root + app.before { env['api.tilt.root'] = override } if override + app.get('/user', rabl: 'user', rabl_locals: { details: 'base template' }) do + @user = Struct.new(:name).new('Jane') + @project = Struct.new(:name).new('First') + @status = 200 + end + end + + after do + Grape::Rabl.reset_configuration! + Grape::Rabl::Formatter.tilt_cache.clear + end + + [false, true].each do |cache| + context "with template caching #{cache ? 'enabled' : 'disabled'}" do + before do + Grape::Rabl.configuration.cache_template_loading = cache + end + + it 'resolves templates and layouts per request without leaking roots' do + user = { 'user' => { 'name' => 'Jane', 'project' => { 'name' => 'First' } } } + base_user = { 'user' => user.fetch('user').merge('details' => 'base template') } + + get '/v1/user' + expect(JSON.parse(last_response.body)).to eq(base_user) + + get '/v2/user' + expect(JSON.parse(last_response.body)).to eq('status' => 200, 'result' => user) + + get '/v1/user' + expect(JSON.parse(last_response.body)).to eq(base_user) + expect(Grape::Rabl.configuration.tilt_root).to equal(root) + end + end + end + + context 'a static configured root' do + let(:root) { File.join(__dir__, 'views') } + + it 'does not automatically append the API version' do + get '/v2/user' + expect(JSON.parse(last_response.body)).to have_key('user') + expect(last_request.env['api.tilt.root']).to eq(root) + end + end + + context 'an explicit request root' do + let(:root) { -> { raise 'configured root should not run' } } + let(:request_root) { File.join(__dir__, 'views') } + + it 'takes precedence over the configured root' do + get '/v1/user' + expect(JSON.parse(last_response.body)).to have_key('user') + end + end + + context 'a missing resolved root' do + let(:root) { -> {} } + + it 'raises the configuration error' do + expect { get '/v1/user' }.to raise_error(RuntimeError, /configure 'tilt_root'/) + end + end + + context 'a failing root resolver' do + let(:root) { -> { raise 'root lookup failed' } } + + it 'propagates the error' do + expect { get '/v1/user' }.to raise_error(RuntimeError, 'root lookup failed') + end + + it 'does not run when falling back to the default formatter' do + app.get('/health') { { healthy: true } } + get '/v1/health' + expect(JSON.parse(last_response.body)).to eq('healthy' => true) + end + end +end + describe 'Grape::Rabl formatter' do subject do Class.new(Grape::API) diff --git a/spec/grape_rabl_spec.rb b/spec/grape_rabl_spec.rb index 39c160a..8a17558 100644 --- a/spec/grape_rabl_spec.rb +++ b/spec/grape_rabl_spec.rb @@ -26,7 +26,7 @@ def app it 'should raise error about root directory' do subject.get('/home', rabl: true) { nil } - expect { get '/home' }.to raise_error(RuntimeError, "Use Rack::Config to set 'api.tilt.root' in config.ru") + expect { get '/home' }.to raise_error(RuntimeError, /Use Rack::Config to set 'api.tilt.root' in config.ru/) end context 'titl root is setup' do