From 8136472e94dba8deda8dedd0f6345b644f4e04cd Mon Sep 17 00:00:00 2001 From: anuj-pal27 Date: Fri, 20 Mar 2026 23:41:58 +0530 Subject: [PATCH 1/5] feat(rendering): add custom renderer DSL --- lib/rage/configuration.rb | 75 ++++++++++ lib/rage/controller/api.rb | 7 + spec/configuration/custom_renderer_spec.rb | 155 +++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 spec/configuration/custom_renderer_spec.rb diff --git a/lib/rage/configuration.rb b/lib/rage/configuration.rb index a10eb126..02a2d134 100644 --- a/lib/rage/configuration.rb +++ b/lib/rage/configuration.rb @@ -28,6 +28,12 @@ class Rage::Configuration # @private include Hooks + def initialize + @renderers = {} + end + + attr_reader :renderers + # @private # used in DSL def config = self @@ -230,6 +236,38 @@ def run_after_initialize! __finalize end + # Register a custom renderer that generates a render_ method on all controllers. + # The block receives the object passed to render_ and any additional keyword arguments. + # The return value of the block is used as the response body. + # + # @param name [Symbol, String] the name of the renderer + # @param block [Proc] the rendering logic + # + # @example Register a CSV renderer + # Rage.configure do + # config.renderer(:csv) do |object, delimiter: ","| + # headers["content-type"] = "text/csv" + # object.join(delimiter) + # end + # end + # + # @example Use in a controller + # class ReportsController < RageController::API + # def index + # render_csv %w[a b c], delimiter: ";", status: :ok + # end + # end + + def renderer(name, &block) + raise ArgumentError, "renderer requires a block" unless block_given? + name = name.to_sym + if @renderers.key?(name) + raise ArgumentError, "a renderer named :#{name} is already registered" + end + dynamic_method_name = RageController::API.define_dynamic_method(block) + @renderers[name] = dynamic_method_name + end + class LogContext # @private def initialize @@ -999,6 +1037,43 @@ def __finalize end Rage::Telemetry.__setup(@telemetry.handlers_map) if @telemetry + + @renderers.each do |name, dynamic_method_name| + method_name = :"render_#{name}" + + # if this method was already defined by a previous finalize run, + # skip it to avoid false conflicts on app reload + existing = RageController::API.__custom_renderers[method_name] + if existing + next + end + + # if the method exists but we didn't define it, someone else owns it — raise + if RageController::API.method_defined?(method_name) + raise ArgumentError, + "cannot register renderer :#{name} — `#{method_name}` is already defined" + end + + # generate the render_ method on RageController::API so every + # controller inherits it automatically + RageController::API.class_eval <<~RUBY + def render_#{name}(*args,status: nil, **kwargs) + raise "Render was called multiple times in this action" if @__rendered + result = #{dynamic_method_name}(*args, **kwargs) + unless @__rendered + @__rendered = true + @__body << result.to_s + @__status = if status.is_a?(Symbol) + ::Rack::Utils::SYMBOL_TO_STATUS_CODE[status] || + raise(ArgumentError, "unknown status code: \#{status.inspect}") + else + status || 200 + end + end + end + RUBY + RageController::API.__custom_renderers[method_name] = true + end end end diff --git a/lib/rage/controller/api.rb b/lib/rage/controller/api.rb index c357ae1b..949f5a6a 100644 --- a/lib/rage/controller/api.rb +++ b/lib/rage/controller/api.rb @@ -2,6 +2,13 @@ class RageController::API class << self + + # Tracks render_ methods defined by custom renderers so re-finalize + # can skip them instead of raising a false conflict error. + def __custom_renderers + @__custom_renderers ||= {} + end + # @private # used by the router to register a new action; # registering means defining a new method which calls the action, makes additional calls (e.g. before actions) and diff --git a/spec/configuration/custom_renderer_spec.rb b/spec/configuration/custom_renderer_spec.rb new file mode 100644 index 00000000..805c4fc5 --- /dev/null +++ b/spec/configuration/custom_renderer_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module ConfigurationCustomRendererSpec + class BaseController < RageController::API + end + end + + RSpec.describe Rage::Configuration do + describe "#renderer / custom renderers" do + let(:config) { described_class.new } + + def build_controller(&block) + klass = Class.new(ConfigurationCustomRendererSpec::BaseController) + klass.class_eval(&block) if block + klass + end + + it "registers a renderer and defines render_ on RageController::API after finalize" do + config.renderer(:csv) do |object, delimiter: ","| + headers["content-type"] = "text/csv" + object.join(delimiter) + end + + config.__finalize + + controller = build_controller do + def index + render_csv %w[a b c], delimiter: ";" + end + end + + expect(run_action(controller, :index)).to eq( + [200, { "content-type" => "text/csv" }, ["a;b;c"]] + ) + end + + it "supports status: on generated render_ method" do + config.renderer(:csv) do |object| + headers["content-type"] = "text/csv" + object.join(",") + end + + config.__finalize + + controller = build_controller do + def index + render_csv %w[a b], status: :created + end + end + + expect(run_action(controller, :index)).to eq( + [201, { "content-type" => "text/csv" }, ["a,b"]] + ) + end + + it "raises when renderer is registered without a block" do + expect { config.renderer(:csv) }.to raise_error(ArgumentError) + end + + it "raises on duplicate renderer names" do + config.renderer(:csv) { "x" } + + expect { + config.renderer(:csv) { "y" } + }.to raise_error(ArgumentError) + end + + it "raises when generated method conflicts with existing API method" do + name = :conflict_renderer + method_name = :"render_#{name}" + + # create a real method so the conflict is real + RageController::API.define_method(method_name) {} + + config.renderer(name) { "x" } + + expect { + config.__finalize + }.to raise_error(ArgumentError, /#{Regexp.escape(method_name.to_s)}/) + ensure + RageController::API.send(:remove_method, method_name) if RageController::API.method_defined?(method_name) + end + + it "executes renderer in controller context (can access headers/request/params)" do + config.renderer(:ctx) do |_| + headers["content-type"] = "text/plain; charset=utf-8" + "id=#{params[:id]}" + end + + config.__finalize + + controller = build_controller do + def index + render_ctx nil + end + end + + expect(run_action(controller, :index, params: { id: 42 })).to eq( + [200, { "content-type" => "text/plain; charset=utf-8" }, ["id=42"]] + ) + end + + it "converts nil return value to empty string body" do + config.renderer(:empty) do |_| + headers["content-type"] = "text/plain; charset=utf-8" + nil + end + + config.__finalize + + controller = build_controller do + def index + render_empty nil + end + end + + expect(run_action(controller, :index)).to eq( + [200, { "content-type" => "text/plain; charset=utf-8" }, [""]] + ) + end + + it "does not double-render when renderer block calls render internally" do + config.renderer(:sse_like) do |_| + render plain: "from-inner-render", status: :accepted + end + + config.__finalize + + controller = build_controller do + def index + render_sse_like nil + end + end + + expect(run_action(controller, :index)).to eq( + [202, { "content-type" => "text/plain; charset=utf-8" }, ["from-inner-render"]] + ) + end + + it "raises if custom renderer is called after already rendering in action" do + config.renderer(:csv) { |_obj| "x" } + config.__finalize + + controller = build_controller do + def index + render plain: "first" + render_csv %w[a b] + end + end + + expect { run_action(controller, :index) } + .to raise_error("Render was called multiple times in this action") + end + end + end \ No newline at end of file From 512bced78219fc0b6f6304458663aa16d665d89f Mon Sep 17 00:00:00 2001 From: anuj-pal27 Date: Wed, 25 Mar 2026 11:45:50 +0530 Subject: [PATCH 2/5] fix(rendering): make custom renderers delegate to render and preserve content-type --- lib/rage/configuration.rb | 70 +++++++++++---------- lib/rage/controller/api.rb | 9 +-- spec/configuration/custom_renderer_spec.rb | 73 +++++++++++++--------- 3 files changed, 81 insertions(+), 71 deletions(-) diff --git a/lib/rage/configuration.rb b/lib/rage/configuration.rb index 02a2d134..74805cbd 100644 --- a/lib/rage/configuration.rb +++ b/lib/rage/configuration.rb @@ -28,12 +28,6 @@ class Rage::Configuration # @private include Hooks - def initialize - @renderers = {} - end - - attr_reader :renderers - # @private # used in DSL def config = self @@ -259,13 +253,14 @@ def run_after_initialize! # end def renderer(name, &block) + @renderers ||= {} raise ArgumentError, "renderer requires a block" unless block_given? name = name.to_sym if @renderers.key?(name) raise ArgumentError, "a renderer named :#{name} is already registered" end - dynamic_method_name = RageController::API.define_dynamic_method(block) - @renderers[name] = dynamic_method_name + dynamic_method_name = Rage::Internal.define_dynamic_method(RageController::API, block) + @renderers[name] = RendererEntry.new(dynamic_method_name) end class LogContext @@ -1038,43 +1033,50 @@ def __finalize Rage::Telemetry.__setup(@telemetry.handlers_map) if @telemetry - @renderers.each do |name, dynamic_method_name| - method_name = :"render_#{name}" + __define_custom_renderers if @renderers + end - # if this method was already defined by a previous finalize run, - # skip it to avoid false conflicts on app reload - existing = RageController::API.__custom_renderers[method_name] - if existing - next - end + # @private + class RendererEntry + attr_reader :dynamic_method_name + + def initialize(dynamic_method_name) + @dynamic_method_name = dynamic_method_name + @applied = false + end + + def applied? = @applied + def applied! = (@applied = true) + end + private_constant :RendererEntry + + def __define_custom_renderers + (@renderers || {}).each do |name, entry| + next if entry.applied? + + method_name = :"render_#{name}" - # if the method exists but we didn't define it, someone else owns it — raise if RageController::API.method_defined?(method_name) + loc = RageController::API.instance_method(method_name).source_location + loc_str = loc ? "#{loc[0]}:#{loc[1]}" : "unknown location" + raise ArgumentError, - "cannot register renderer :#{name} — `#{method_name}` is already defined" + "cannot register renderer :#{name} — `#{method_name}` is already defined at #{loc_str}" end - # generate the render_ method on RageController::API so every - # controller inherits it automatically RageController::API.class_eval <<~RUBY - def render_#{name}(*args,status: nil, **kwargs) - raise "Render was called multiple times in this action" if @__rendered - result = #{dynamic_method_name}(*args, **kwargs) - unless @__rendered - @__rendered = true - @__body << result.to_s - @__status = if status.is_a?(Symbol) - ::Rack::Utils::SYMBOL_TO_STATUS_CODE[status] || - raise(ArgumentError, "unknown status code: \#{status.inspect}") - else - status || 200 - end - end + def render_#{name}(*args, status: nil, **kwargs) + raise "Render was called multiple times in this action." if @__rendered + result = #{entry.dynamic_method_name}(*args, **kwargs) + return if @__rendered + render plain: result.to_s, status: (status || 200) end RUBY - RageController::API.__custom_renderers[method_name] = true + + entry.applied! end end + private :__define_custom_renderers end # @!parse [ruby] diff --git a/lib/rage/controller/api.rb b/lib/rage/controller/api.rb index 949f5a6a..6125e520 100644 --- a/lib/rage/controller/api.rb +++ b/lib/rage/controller/api.rb @@ -2,13 +2,6 @@ class RageController::API class << self - - # Tracks render_ methods defined by custom renderers so re-finalize - # can skip them instead of raising a false conflict error. - def __custom_renderers - @__custom_renderers ||= {} - end - # @private # used by the router to register a new action; # registering means defining a new method which calls the action, makes additional calls (e.g. before actions) and @@ -515,7 +508,7 @@ def render(json: nil, plain: nil, sse: nil, status: nil) @__body << if json json.is_a?(String) ? json : json.to_json else - @__headers["content-type"] = "text/plain; charset=utf-8" + @__headers["content-type"] ||= "text/plain; charset=utf-8" plain.to_s end diff --git a/spec/configuration/custom_renderer_spec.rb b/spec/configuration/custom_renderer_spec.rb index 805c4fc5..5319c85c 100644 --- a/spec/configuration/custom_renderer_spec.rb +++ b/spec/configuration/custom_renderer_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true +require "securerandom" module ConfigurationCustomRendererSpec class BaseController < RageController::API end - end +end RSpec.describe Rage::Configuration do describe "#renderer / custom renderers" do @@ -14,9 +15,15 @@ def build_controller(&block) klass.class_eval(&block) if block klass end + + def unique_renderer_name(base) + :"#{base}_#{SecureRandom.hex(4)}" + end it "registers a renderer and defines render_ on RageController::API after finalize" do - config.renderer(:csv) do |object, delimiter: ","| + name = unique_renderer_name(:csv) + + config.renderer(name) do |object, delimiter: ","| headers["content-type"] = "text/csv" object.join(delimiter) end @@ -24,8 +31,8 @@ def build_controller(&block) config.__finalize controller = build_controller do - def index - render_csv %w[a b c], delimiter: ";" + define_method(:index) do + public_send(:"render_#{name}", %w[a b c], delimiter: ";") end end @@ -35,7 +42,9 @@ def index end it "supports status: on generated render_ method" do - config.renderer(:csv) do |object| + name = unique_renderer_name(:csv) + + config.renderer(name) do |object| headers["content-type"] = "text/csv" object.join(",") end @@ -43,8 +52,8 @@ def index config.__finalize controller = build_controller do - def index - render_csv %w[a b], status: :created + define_method(:index) do + public_send(:"render_#{name}", %w[a b], status: :created) end end @@ -58,15 +67,16 @@ def index end it "raises on duplicate renderer names" do - config.renderer(:csv) { "x" } + name = unique_renderer_name(:csv) + config.renderer(name) { "x" } expect { - config.renderer(:csv) { "y" } + config.renderer(name) { "y" } }.to raise_error(ArgumentError) end it "raises when generated method conflicts with existing API method" do - name = :conflict_renderer + name = unique_renderer_name(:conflict) method_name = :"render_#{name}" # create a real method so the conflict is real @@ -82,7 +92,8 @@ def index end it "executes renderer in controller context (can access headers/request/params)" do - config.renderer(:ctx) do |_| + name = unique_renderer_name(:ctx) + config.renderer(name) do |_| headers["content-type"] = "text/plain; charset=utf-8" "id=#{params[:id]}" end @@ -90,8 +101,8 @@ def index config.__finalize controller = build_controller do - def index - render_ctx nil + define_method(:index) do + public_send(:"render_#{name}", nil) end end @@ -101,7 +112,8 @@ def index end it "converts nil return value to empty string body" do - config.renderer(:empty) do |_| + name = unique_renderer_name(:empty) + config.renderer(name) do |_| headers["content-type"] = "text/plain; charset=utf-8" nil end @@ -109,8 +121,8 @@ def index config.__finalize controller = build_controller do - def index - render_empty nil + define_method(:index) do + public_send(:"render_#{name}", nil) end end @@ -120,36 +132,39 @@ def index end it "does not double-render when renderer block calls render internally" do - config.renderer(:sse_like) do |_| + name = unique_renderer_name(:sse_like) + + config.renderer(name) do |_| render plain: "from-inner-render", status: :accepted end - + config.__finalize - + controller = build_controller do - def index - render_sse_like nil + define_method(:index) do + public_send(:"render_#{name}", nil) end end - - expect(run_action(controller, :index)).to eq( - [202, { "content-type" => "text/plain; charset=utf-8" }, ["from-inner-render"]] - ) + + status, _headers, body = run_action(controller, :index) + expect(status).to eq(202) + expect(body).to eq(["from-inner-render"]) end it "raises if custom renderer is called after already rendering in action" do - config.renderer(:csv) { |_obj| "x" } + name = unique_renderer_name(:csv) + config.renderer(name) { |_obj| "x" } config.__finalize controller = build_controller do - def index + define_method(:index) do render plain: "first" - render_csv %w[a b] + public_send(:"render_#{name}", %w[a b]) end end expect { run_action(controller, :index) } - .to raise_error("Render was called multiple times in this action") + .to raise_error("Render was called multiple times in this action.") end end end \ No newline at end of file From e31bd764b643f212ddc3c98ca1a341aa0c7c5370 Mon Sep 17 00:00:00 2001 From: anuj-pal27 Date: Wed, 25 Mar 2026 20:18:26 +0530 Subject: [PATCH 3/5] style(spec): fix formatting in custom_renderer_spec --- spec/configuration/custom_renderer_spec.rb | 293 +++++++++++---------- 1 file changed, 147 insertions(+), 146 deletions(-) diff --git a/spec/configuration/custom_renderer_spec.rb b/spec/configuration/custom_renderer_spec.rb index 5319c85c..3ec45828 100644 --- a/spec/configuration/custom_renderer_spec.rb +++ b/spec/configuration/custom_renderer_spec.rb @@ -1,170 +1,171 @@ # frozen_string_literal: true + require "securerandom" module ConfigurationCustomRendererSpec - class BaseController < RageController::API - end + class BaseController < RageController::API + end end - - RSpec.describe Rage::Configuration do - describe "#renderer / custom renderers" do - let(:config) { described_class.new } - - def build_controller(&block) - klass = Class.new(ConfigurationCustomRendererSpec::BaseController) - klass.class_eval(&block) if block - klass - end - def unique_renderer_name(base) - :"#{base}_#{SecureRandom.hex(4)}" +RSpec.describe Rage::Configuration do + describe "#renderer / custom renderers" do + let(:config) { described_class.new } + + def build_controller(&block) + klass = Class.new(ConfigurationCustomRendererSpec::BaseController) + klass.class_eval(&block) if block + klass + end + + def unique_renderer_name(base) + :"#{base}_#{SecureRandom.hex(4)}" + end + + it "registers a renderer and defines render_ on RageController::API after finalize" do + name = unique_renderer_name(:csv) + + config.renderer(name) do |object, delimiter: ","| + headers["content-type"] = "text/csv" + object.join(delimiter) end - - it "registers a renderer and defines render_ on RageController::API after finalize" do - name = unique_renderer_name(:csv) - config.renderer(name) do |object, delimiter: ","| - headers["content-type"] = "text/csv" - object.join(delimiter) - end - - config.__finalize - - controller = build_controller do - define_method(:index) do - public_send(:"render_#{name}", %w[a b c], delimiter: ";") - end + config.__finalize + + controller = build_controller do + define_method(:index) do + public_send(:"render_#{name}", %w[a b c], delimiter: ";") end - - expect(run_action(controller, :index)).to eq( - [200, { "content-type" => "text/csv" }, ["a;b;c"]] - ) end - - it "supports status: on generated render_ method" do - name = unique_renderer_name(:csv) - config.renderer(name) do |object| - headers["content-type"] = "text/csv" - object.join(",") - end - - config.__finalize - - controller = build_controller do - define_method(:index) do - public_send(:"render_#{name}", %w[a b], status: :created) - end + expect(run_action(controller, :index)).to eq( + [200, { "content-type" => "text/csv" }, ["a;b;c"]] + ) + end + + it "supports status: on generated render_ method" do + name = unique_renderer_name(:csv) + + config.renderer(name) do |object| + headers["content-type"] = "text/csv" + object.join(",") + end + + config.__finalize + + controller = build_controller do + define_method(:index) do + public_send(:"render_#{name}", %w[a b], status: :created) end - - expect(run_action(controller, :index)).to eq( - [201, { "content-type" => "text/csv" }, ["a,b"]] - ) end - - it "raises when renderer is registered without a block" do - expect { config.renderer(:csv) }.to raise_error(ArgumentError) + + expect(run_action(controller, :index)).to eq( + [201, { "content-type" => "text/csv" }, ["a,b"]] + ) + end + + it "raises when renderer is registered without a block" do + expect { config.renderer(:csv) }.to raise_error(ArgumentError) + end + + it "raises on duplicate renderer names" do + name = unique_renderer_name(:csv) + config.renderer(name) { "x" } + + expect { + config.renderer(name) { "y" } + }.to raise_error(ArgumentError) + end + + it "raises when generated method conflicts with existing API method" do + name = unique_renderer_name(:conflict) + method_name = :"render_#{name}" + + # create a real method so the conflict is real + RageController::API.define_method(method_name) {} + + config.renderer(name) { "x" } + + expect { + config.__finalize + }.to raise_error(ArgumentError, /#{Regexp.escape(method_name.to_s)}/) + ensure + RageController::API.send(:remove_method, method_name) if RageController::API.method_defined?(method_name) + end + + it "executes renderer in controller context (can access headers/request/params)" do + name = unique_renderer_name(:ctx) + config.renderer(name) do |_| + headers["content-type"] = "text/plain; charset=utf-8" + "id=#{params[:id]}" end - - it "raises on duplicate renderer names" do - name = unique_renderer_name(:csv) - config.renderer(name) { "x" } - - expect { - config.renderer(name) { "y" } - }.to raise_error(ArgumentError) + + config.__finalize + + controller = build_controller do + define_method(:index) do + public_send(:"render_#{name}", nil) + end end - - it "raises when generated method conflicts with existing API method" do - name = unique_renderer_name(:conflict) - method_name = :"render_#{name}" - - # create a real method so the conflict is real - RageController::API.define_method(method_name) {} - - config.renderer(name) { "x" } - - expect { - config.__finalize - }.to raise_error(ArgumentError, /#{Regexp.escape(method_name.to_s)}/) - ensure - RageController::API.send(:remove_method, method_name) if RageController::API.method_defined?(method_name) + + expect(run_action(controller, :index, params: { id: 42 })).to eq( + [200, { "content-type" => "text/plain; charset=utf-8" }, ["id=42"]] + ) + end + + it "converts nil return value to empty string body" do + name = unique_renderer_name(:empty) + config.renderer(name) do |_| + headers["content-type"] = "text/plain; charset=utf-8" + nil end - - it "executes renderer in controller context (can access headers/request/params)" do - name = unique_renderer_name(:ctx) - config.renderer(name) do |_| - headers["content-type"] = "text/plain; charset=utf-8" - "id=#{params[:id]}" - end - - config.__finalize - - controller = build_controller do - define_method(:index) do - public_send(:"render_#{name}", nil) - end + + config.__finalize + + controller = build_controller do + define_method(:index) do + public_send(:"render_#{name}", nil) end - - expect(run_action(controller, :index, params: { id: 42 })).to eq( - [200, { "content-type" => "text/plain; charset=utf-8" }, ["id=42"]] - ) end - - it "converts nil return value to empty string body" do - name = unique_renderer_name(:empty) - config.renderer(name) do |_| - headers["content-type"] = "text/plain; charset=utf-8" - nil - end - - config.__finalize - - controller = build_controller do - define_method(:index) do - public_send(:"render_#{name}", nil) - end - end - - expect(run_action(controller, :index)).to eq( - [200, { "content-type" => "text/plain; charset=utf-8" }, [""]] - ) + + expect(run_action(controller, :index)).to eq( + [200, { "content-type" => "text/plain; charset=utf-8" }, [""]] + ) + end + + it "does not double-render when renderer block calls render internally" do + name = unique_renderer_name(:sse_like) + + config.renderer(name) do |_| + render plain: "from-inner-render", status: :accepted end - - it "does not double-render when renderer block calls render internally" do - name = unique_renderer_name(:sse_like) - - config.renderer(name) do |_| - render plain: "from-inner-render", status: :accepted - end - - config.__finalize - - controller = build_controller do - define_method(:index) do - public_send(:"render_#{name}", nil) - end + + config.__finalize + + controller = build_controller do + define_method(:index) do + public_send(:"render_#{name}", nil) end - - status, _headers, body = run_action(controller, :index) - expect(status).to eq(202) - expect(body).to eq(["from-inner-render"]) end - - it "raises if custom renderer is called after already rendering in action" do - name = unique_renderer_name(:csv) - config.renderer(name) { |_obj| "x" } - config.__finalize - - controller = build_controller do - define_method(:index) do - render plain: "first" - public_send(:"render_#{name}", %w[a b]) - end + + status, _headers, body = run_action(controller, :index) + expect(status).to eq(202) + expect(body).to eq(["from-inner-render"]) + end + + it "raises if custom renderer is called after already rendering in action" do + name = unique_renderer_name(:csv) + config.renderer(name) { |_obj| "x" } + config.__finalize + + controller = build_controller do + define_method(:index) do + render plain: "first" + public_send(:"render_#{name}", %w[a b]) end - - expect { run_action(controller, :index) } - .to raise_error("Render was called multiple times in this action.") end + + expect { run_action(controller, :index) }. + to raise_error("Render was called multiple times in this action.") end - end \ No newline at end of file + end +end From 278447b755347257f3ba8f3b5024a9c1dff2621c Mon Sep 17 00:00:00 2001 From: anuj-pal27 Date: Wed, 25 Mar 2026 23:16:17 +0530 Subject: [PATCH 4/5] fix(rendering): preserve content-type when delegating custom renderers to render --- lib/rage/configuration.rb | 4 +--- lib/rage/controller/api.rb | 9 +++++++-- spec/configuration/custom_renderer_spec.rb | 7 ++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/rage/configuration.rb b/lib/rage/configuration.rb index 74805cbd..ba50b15a 100644 --- a/lib/rage/configuration.rb +++ b/lib/rage/configuration.rb @@ -251,7 +251,6 @@ def run_after_initialize! # render_csv %w[a b c], delimiter: ";", status: :ok # end # end - def renderer(name, &block) @renderers ||= {} raise ArgumentError, "renderer requires a block" unless block_given? @@ -1051,7 +1050,7 @@ def applied! = (@applied = true) private_constant :RendererEntry def __define_custom_renderers - (@renderers || {}).each do |name, entry| + @renderers.each do |name, entry| next if entry.applied? method_name = :"render_#{name}" @@ -1066,7 +1065,6 @@ def __define_custom_renderers RageController::API.class_eval <<~RUBY def render_#{name}(*args, status: nil, **kwargs) - raise "Render was called multiple times in this action." if @__rendered result = #{entry.dynamic_method_name}(*args, **kwargs) return if @__rendered render plain: result.to_s, status: (status || 200) diff --git a/lib/rage/controller/api.rb b/lib/rage/controller/api.rb index 6125e520..44883378 100644 --- a/lib/rage/controller/api.rb +++ b/lib/rage/controller/api.rb @@ -445,11 +445,15 @@ def prepare_action_params(action_name = nil, **opts, &block) end end # class << self + DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8" + DEFAULT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8" + private_constant :DEFAULT_CONTENT_TYPE, :DEFAULT_PLAIN_CONTENT_TYPE + # @private def initialize(env, params) @__env = env @__params = params - @__status, @__headers, @__body = 204, { "content-type" => "application/json; charset=utf-8" }, [] + @__status, @__headers, @__body = 204, { "content-type" => DEFAULT_CONTENT_TYPE }, [] @__rendered = false end @@ -508,7 +512,8 @@ def render(json: nil, plain: nil, sse: nil, status: nil) @__body << if json json.is_a?(String) ? json : json.to_json else - @__headers["content-type"] ||= "text/plain; charset=utf-8" + ct = @__headers["content-type"] + @__headers["content-type"] = DEFAULT_PLAIN_CONTENT_TYPE if ct.nil? || ct == DEFAULT_CONTENT_TYPE plain.to_s end diff --git a/spec/configuration/custom_renderer_spec.rb b/spec/configuration/custom_renderer_spec.rb index 3ec45828..5a845575 100644 --- a/spec/configuration/custom_renderer_spec.rb +++ b/spec/configuration/custom_renderer_spec.rb @@ -152,7 +152,7 @@ def unique_renderer_name(base) expect(body).to eq(["from-inner-render"]) end - it "raises if custom renderer is called after already rendering in action" do + it "does not raise if custom renderer is called after already rendering in action" do name = unique_renderer_name(:csv) config.renderer(name) { |_obj| "x" } config.__finalize @@ -164,8 +164,9 @@ def unique_renderer_name(base) end end - expect { run_action(controller, :index) }. - to raise_error("Render was called multiple times in this action.") + status, _headers, body = run_action(controller, :index) + expect(status).to eq(200) + expect(body).to eq(["first"]) end end end From 405450b42d6f6a5846a31581198b2f5b6d92429f Mon Sep 17 00:00:00 2001 From: anuj-pal27 Date: Fri, 27 Mar 2026 10:49:29 +0530 Subject: [PATCH 5/5] Refactor: Move custom renderer definition to and enforce single render per action. --- lib/rage/configuration.rb | 29 ++++------------------ lib/rage/controller/api.rb | 29 +++++++++++++++++++--- spec/configuration/custom_renderer_spec.rb | 6 ++--- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/lib/rage/configuration.rb b/lib/rage/configuration.rb index ba50b15a..e39b03eb 100644 --- a/lib/rage/configuration.rb +++ b/lib/rage/configuration.rb @@ -258,8 +258,7 @@ def renderer(name, &block) if @renderers.key?(name) raise ArgumentError, "a renderer named :#{name} is already registered" end - dynamic_method_name = Rage::Internal.define_dynamic_method(RageController::API, block) - @renderers[name] = RendererEntry.new(dynamic_method_name) + @renderers[name] = RendererEntry.new(block) end class LogContext @@ -1037,10 +1036,10 @@ def __finalize # @private class RendererEntry - attr_reader :dynamic_method_name + attr_reader :block - def initialize(dynamic_method_name) - @dynamic_method_name = dynamic_method_name + def initialize(block) + @block = block @applied = false end @@ -1052,25 +1051,7 @@ def applied! = (@applied = true) def __define_custom_renderers @renderers.each do |name, entry| next if entry.applied? - - method_name = :"render_#{name}" - - if RageController::API.method_defined?(method_name) - loc = RageController::API.instance_method(method_name).source_location - loc_str = loc ? "#{loc[0]}:#{loc[1]}" : "unknown location" - - raise ArgumentError, - "cannot register renderer :#{name} — `#{method_name}` is already defined at #{loc_str}" - end - - RageController::API.class_eval <<~RUBY - def render_#{name}(*args, status: nil, **kwargs) - result = #{entry.dynamic_method_name}(*args, **kwargs) - return if @__rendered - render plain: result.to_s, status: (status || 200) - end - RUBY - + RageController::API.__register_renderer(name, entry.block) entry.applied! end end diff --git a/lib/rage/controller/api.rb b/lib/rage/controller/api.rb index 44883378..492e3fc9 100644 --- a/lib/rage/controller/api.rb +++ b/lib/rage/controller/api.rb @@ -211,6 +211,30 @@ def __rage_dynamic_#{name}(condition) RUBY end + # @private + def __register_renderer(name, block) + method_name = :"render_#{name}" + + if method_defined?(method_name) + loc = instance_method(method_name).source_location + loc_str = loc ? "#{loc[0]}:#{loc[1]}" : "unknown location" + + raise ArgumentError, + "cannot register renderer :#{name} — `#{method_name}` is already defined at #{loc_str}" + end + + dynamic_method_name = Rage::Internal.define_dynamic_method(self, block) + + class_eval <<~RUBY + def render_#{name}(*args, status: nil, **kwargs) + raise "Render was called multiple times in this action." if @__rendered + result = #{dynamic_method_name}(*args, **kwargs) + return if @__rendered + render plain: result.to_s, status: (status || 200) + end + RUBY + end + ############ # # PUBLIC API @@ -446,8 +470,7 @@ def prepare_action_params(action_name = nil, **opts, &block) end # class << self DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8" - DEFAULT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8" - private_constant :DEFAULT_CONTENT_TYPE, :DEFAULT_PLAIN_CONTENT_TYPE + private_constant :DEFAULT_CONTENT_TYPE # @private def initialize(env, params) @@ -513,7 +536,7 @@ def render(json: nil, plain: nil, sse: nil, status: nil) json.is_a?(String) ? json : json.to_json else ct = @__headers["content-type"] - @__headers["content-type"] = DEFAULT_PLAIN_CONTENT_TYPE if ct.nil? || ct == DEFAULT_CONTENT_TYPE + @__headers["content-type"] = "text/plain; charset=utf-8" if ct.nil? || ct == DEFAULT_CONTENT_TYPE plain.to_s end diff --git a/spec/configuration/custom_renderer_spec.rb b/spec/configuration/custom_renderer_spec.rb index 5a845575..50f92a43 100644 --- a/spec/configuration/custom_renderer_spec.rb +++ b/spec/configuration/custom_renderer_spec.rb @@ -152,7 +152,7 @@ def unique_renderer_name(base) expect(body).to eq(["from-inner-render"]) end - it "does not raise if custom renderer is called after already rendering in action" do + it "raises if custom renderer is called after already rendering in action" do name = unique_renderer_name(:csv) config.renderer(name) { |_obj| "x" } config.__finalize @@ -164,9 +164,7 @@ def unique_renderer_name(base) end end - status, _headers, body = run_action(controller, :index) - expect(status).to eq(200) - expect(body).to eq(["first"]) + expect { run_action(controller, :index) }.to raise_error(/Render was called multiple times in this action/) end end end