diff --git a/lib/rage/configuration.rb b/lib/rage/configuration.rb index a10eb126..e39b03eb 100644 --- a/lib/rage/configuration.rb +++ b/lib/rage/configuration.rb @@ -230,6 +230,37 @@ 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) + @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 + @renderers[name] = RendererEntry.new(block) + end + class LogContext # @private def initialize @@ -999,7 +1030,32 @@ def __finalize end Rage::Telemetry.__setup(@telemetry.handlers_map) if @telemetry + + __define_custom_renderers if @renderers + end + + # @private + class RendererEntry + attr_reader :block + + def initialize(block) + @block = block + @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? + RageController::API.__register_renderer(name, entry.block) + 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 c357ae1b..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 @@ -445,11 +469,14 @@ def prepare_action_params(action_name = nil, **opts, &block) end end # class << self + DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8" + private_constant :DEFAULT_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 +535,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"] = "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 new file mode 100644 index 00000000..50f92a43 --- /dev/null +++ b/spec/configuration/custom_renderer_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "securerandom" + +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 + + 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 + + config.__finalize + + controller = build_controller do + define_method(:index) do + public_send(:"render_#{name}", %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 + 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 + 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 + 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 + + config.__finalize + + controller = build_controller do + define_method(:index) do + public_send(:"render_#{name}", 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 + 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" }, [""]] + ) + 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 + 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 + end + + expect { run_action(controller, :index) }.to raise_error(/Render was called multiple times in this action/) + end + end +end