Skip to content

[Rendering] Custom renderer support#244

Open
anuj-pal27 wants to merge 1 commit intorage-rb:mainfrom
anuj-pal27:feat/custom-renderers-234
Open

[Rendering] Custom renderer support#244
anuj-pal27 wants to merge 1 commit intorage-rb:mainfrom
anuj-pal27:feat/custom-renderers-234

Conversation

@anuj-pal27
Copy link
Contributor

@anuj-pal27 anuj-pal27 commented Mar 20, 2026

Summary

Implements the config.renderer(:name) { ... } DSL proposed in the issue.
Registers custom renderer blocks and generates render_<name> methods on
RageController::API at boot time.

Key behavior

  • config.renderer(:csv) { |obj, **opts| ... } registers a renderer block
  • After __finalize, every controller automatically gets render_csv, render_phlex, etc.
  • Generated methods support status: as both Symbol (:created) and Integer (201)
  • Unknown status symbols raise ArgumentError instead of setting nil status
  • Renderer blocks run in controller context — headers, request, params all work
  • Double-render is prevented whether render happens via return value or internal render call
  • RageController::API.__custom_renderers registry makes finalize idempotent across re-runs

Tests

9 specs covering:

  • Basic registration and method generation
  • Status handling (symbol, integer, default 200)
  • Duplicate renderer registration guard
  • Method name conflict detection
  • Controller context access (headers, params)
  • nil return value → empty string body
  • Renderer block calling render internally
  • Double-render protection

Notes

Renderer blocks are converted to named methods via define_dynamic_method rather
than using instance_exec per request — this keeps the hot path allocation-free
and consistent with how Rage handles action methods internally.

Fixes: #234

Screenshots

CSV renderer:
Screenshot from 2026-03-21 00-03-07

Phlex renderer:
Screenshot from 2026-03-21 00-06-09

Copy link
Member

@rsamoilov rsamoilov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @anuj-pal27

That's a great start! I've left some comments. Please let me know if you have any questions.

Comment on lines +1061 to +1071
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the logic from RageController::API#render, and it will critically complicate maintaining and extending the render method.

You should delegate the actual rendering to the render method itself - it already has the logic to store the body, convert the status, and set and validate the @__rendered flag.

The reason this is currently impossible is because render plain: ... always sets content type to text/plain; charset=utf-8. If you call render after calling the renderer proc, it will overwrite the content type that the user wanted to set in the proc. The solution I'd propose would be to ensure that render plain: ... only updates content type if it wasn't already set by the user. This way you can safely call render after calling the renderer proc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it! I'll delegate to render instead and fix render plain: to use ||= for content-type so it doesn't overwrite what the renderer block sets.

in lib/rage/controller/api.rb -> render method

@__headers["content-type"] ||= "text/plain; charset=utf-8"

RageController::API.class_eval <<~RUBY
  def render_#{name}(*args, status: nil, **kwargs)
    result = #{dynamic_method_name}(*args, **kwargs)
    render plain: result, status: status
  end
RUBY

is this correct?

@renderers = {}
end

attr_reader :renderers
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this being used anywhere. Could you please add documentation to this method or remove it?

Comment on lines +31 to +33
def initialize
@renderers = {}
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move the initialization into the renderer method itself:

def renderer(name, &block)
  @renderers ||= {}
  # ...
end

if @renderers.key?(name)
raise ArgumentError, "a renderer named :#{name} is already registered"
end
dynamic_method_name = RageController::API.define_dynamic_method(block)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

define_dynamic_method in RageController::API is obsolete - let's use the similar method from the Rage::Internal module instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okk i will switch to Rage::Internal helper

dynamic_method_name = Rage::Internal.define_dynamic_method(RageController::API, block)


Rage::Telemetry.__setup(@telemetry.handlers_map) if @telemetry

@renderers.each do |name, dynamic_method_name|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please extract this loop into a separate method?

# 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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great 👍

Let's also add the source location of the method to the error so that the user can see where exactly the method is already defined.


# 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]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's the responsibility of the configuration class to track the applied custom renderers and ensure it doesn't reapply them. We shouldn't change RageController::API to implement this tracking.

One way to contain this logic inside the configuration class is to wrap renderer procs into a wrapper class inside the renderer method. The wrapper class would expose the proc itself and the applied? flag. Then, inside the finalize loop you would verify applied? is false and toggle it.

Comment on lines +6 to +10
# Tracks render_<name> methods defined by custom renderers so re-finalize
# can skip them instead of raising a false conflict error.
def __custom_renderers
@__custom_renderers ||= {}
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my suggestion about containing the tracking logic inside Configuration.

Suggested change
# Tracks render_<name> methods defined by custom renderers so re-finalize
# can skip them instead of raising a false conflict error.
def __custom_renderers
@__custom_renderers ||= {}
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Rendering] Custom renderer support

2 participants