Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make it easy to render templates outside of controllers #18409

Closed
dhh opened this issue Jan 8, 2015 · 16 comments
Closed

Make it easy to render templates outside of controllers #18409

dhh opened this issue Jan 8, 2015 · 16 comments
Milestone

Comments

@dhh
Copy link
Member

dhh commented Jan 8, 2015

While templates depend on certain aspects from the controller to be rendered properly, you are often in a scenario where you might need to render something outside of a controller. Examples include jobs, auxiliary scripts, web sockets, etc.

For this purpose, I've been using a RenderMan class like follows:

# Would have called this ActionView::Renderer, but that name is already taken
class ActionView::RenderMan
  cattr_accessor(:default_host_options) do
    { "HTTP_HOST"   => 'example.com',
      "SCRIPT_NAME" => '',
      "HTTPS"       => Rails.env.development? ? "off" : "on",
      "rack.input"  => '' }
  end

  def self.render(*args)
    new(*args).render
  end

  def initialize(host_options: Hash.new, **rendering_options)
    @host_options, @rendering_options = host_options, rendering_options
  end

  def render
    with_script_name { controller.render_to_string @rendering_options }
  end

  def controller
    ApplicationController.new.tap do |controller|
      controller.request  = ActionDispatch::Request.new default_host_options.merge(@host_options)
      controller.response = ActionDispatch::Response.new

      controller.params = {}
      Array(@rendering_options[:assigns]).each { |k, v| controller.instance_variable_set "@#{k}", v }
    end
  end

  private
    def with_script_name
      if @host_options["SCRIPT_NAME"]
        begin
          original_default_url_options = Rails.application.routes.default_url_options
          Rails.application.routes.default_url_options = { script_name: @host_options["SCRIPT_NAME"] }
          yield
        ensure
          Rails.application.routes.default_url_options = original_default_url_options
        end
      else
        yield
      end
    end
end

In our application, we've further specialized this class into an ApplicationRenderer that looks like so:

class ApplicationRenderer < ActionView::RenderMan
  self.default_host_options['HTTP_HOST'] = BC3.domain.hostname

  def self.render(account:, **params)
    host_options = (params[:host_options].presence || Hash.new).merge({ 'SCRIPT_NAME' => account.slug_path })
    new(params.merge(host_options: host_options)).render
  end
end

It's used as such:

ApplicationRenderer.render(account: bucket.account, template: 'webhooks/event', assigns: { event: event })
ApplicationRenderer.render(account: account, partial: "chats/lines/#{line_partial}", locals: locals)

(This specialization is mostly just to show the justification of the SCRIPT_NAME munging).

I'd like to see RenderMan go into ActionView, or, if there's a simpler way to achieve the same, then that. I'd also like to find a new class name.

@dhh dhh added the actionview label Jan 8, 2015
@dhh dhh added this to the 5.0.0 milestone Jan 8, 2015
@pseidemann
Copy link
Contributor

this might be related to #11662. I also like to see this built into rails

@dhh
Copy link
Member Author

dhh commented Jan 9, 2015

Yup, attacking the same issue.

On Jan 8, 2015, at 6:28 PM, pseidemann notifications@github.com wrote:

this might be related to #11662 #11662. I also like to see this built into rails


Reply to this email directly or view it on GitHub #18409 (comment).

@kaspth
Copy link
Contributor

kaspth commented Jan 10, 2015

@dhh as for the name, what about something like UnboundRenderer or OmniRenderer?

@brainopia
Copy link
Contributor

I'll take a look, if the issue is still unassigned.

@kaspth
Copy link
Contributor

kaspth commented Jan 11, 2015

Go for it.

@dhh
Copy link
Member Author

dhh commented Jan 11, 2015

Hmm, there's something to UnboundRenderer but it's too indirect imo. Maybe StringRenderer could do. That's what it does. Returns the string.

Anyway, @brainopia please do take a stab at this!

@simi
Copy link
Contributor

simi commented Jan 11, 2015

What about DirectRenderer?

@dhh
Copy link
Member Author

dhh commented Jan 11, 2015

Hmm. “Direct” is a nice word, but I don’t know if it necessarily implies any more clarity. At some point it is what we define it to be, and it doesn’t matter that much what it really is, I suppose.

On Jan 11, 2015, at 11:22 AM, Josef Šimánek notifications@github.com wrote:

What about DirectRenderer?


Reply to this email directly or view it on GitHub #18409 (comment).

@brainopia
Copy link
Contributor

Hello, David. I've been pondering about this feature. In the example above a request environment consists of default_host_options and host_options. But at least some of gems depend on environment to contain data populated by middleware.

An example of this would be Devise. A call to current_user would fail unless env['warden'] was setup by Warden middleware. It would be tiresome to manually configure host_options/default_host_options in such scenario.

What if we'd have a class that could gather and return a request environment for a given URL by passing it through middleware stack but stopping at application routes.

env = ActionDispatch::Middleware::Env.for 'https://basecamp.com/xxx/projects/yyy',
                                           method: 'GET',
                                           headers: { optional_headers }

then it could be used as host_options/default_host_options.

It would handle correctly setting all environment variables from your example (HTTPS, HOST_NAME, rack.input, SCRIPT_NAME (if it's been setup by application middleware) and many others (e.g., warden)).

And we can wrap ActionDispatch::Middleware::Env + ActionView::StringRenderer
in a additional class to get a simple interface to render arbitrary template for arbitrary request if overhead of calling a middleware stack is acceptable. And if it's not then ActionView::StringRenderer is available.

Also regarding ActionView::StringRenderer#with_script_name is it necessary in rails 4? Judging by https://github.com/rails/rails/blob/master/actionpack/lib/action_controller/metal/url_for.rb#L41 SCRIPT_NAME should be accounted directly from environment without this method. I've tested in application and it has worked for me. May be I'm missing something?

@dhh
Copy link
Member Author

dhh commented Jan 14, 2015

I like the idea of that! Please give that a go in a PR, and let's try it out. Be happy to test it in our Basecamp stuff. Thanks for working on this.

@rafaelfranca
Copy link
Member

Closed by #18546

@assimovt
Copy link
Contributor

assimovt commented Dec 8, 2015

What's the easiest way to get the similar behavior with Rails 4? I am trying to get actioncable-examples working with 4.2.5.

@brainopia
Copy link
Contributor

@assimovt here you go https://github.com/brainopia/backport_new_renderer

@assimovt
Copy link
Contributor

assimovt commented Dec 8, 2015

@brainopia Was just reading your Medium post ;) Thanks for the link, will try it out!

@rafaelfranca rafaelfranca modified the milestones: 5.0.0 [temp], 5.0.0 Dec 30, 2015
@jclusso
Copy link

jclusso commented Nov 29, 2016

@dhh I've found myself at this issue after reviewing #18546 and some other related links.

http://www.thegreatcodeadventure.com/using-action-controller-renderers-in-rails-5-with-devise/
https://evilmartians.com/chronicles/new-feature-in-rails-5-render-views-outside-of-actions

What I'm not understanding is how this would ever work in a real world application. Your video on Rails 5 Action Cable shows off this code, but what you do is not really possible if you're using any authentication or anything that requires middleware like Devise.

In the use case you've highlighted in your video, you are rendering views from an asynchronous worker. The only way this would be possible is to pass the request environment in which is basically impossible to serialize.

Am I missing something?

@dhh
Copy link
Member Author

dhh commented Dec 1, 2016

I don't use Devise, so don't know what that looks like. But you're just rendering the views, not the controller actions. You don't need to have all envs available to do that. If you need that, your code is too tangled. You can just pass the ivar/local vars that the specific template/partial needs.

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

No branches or pull requests

8 participants