Tuesday, February 5, 2008

ActsAsRenderer Brings Output to Models

Judging by the fact that there are several posts about this topic out in the wild, and that I have come across a need for it more than once, I thought it would be helpful to wrap up this functionality into a plugin and put it out into the world. Give a warm welcome to ActsAsRenderer!

Before you go off on a tirade about the evils of violating MVC, let me first say I know the arguments and I agree with you. However, in a world of complex systems where not everything is done via full-stack HTTP, there are legitimate reasons to output data directly from models, and ActsAsRenderer helps you do it.

With ActsAsRenderer, you get four cool new functions.

For your model class, you get render_file and render_string. For your instances, you get render_to_file and render_to_string.

Probably the most common (and legitimate) use of this kind of functionality is for rendering data out of a Rails script (say with script/runner). Since that environment is not a full-stack HTTP view of the world, it's a real pain to render any kind of structured output. Not anymore! With acts_as_renderer in your model, you can render your views and give your model the voice it's been lacking!

I've had this need come up several times. Most recently, I built a server configuration management system using Rails. While it is nice to preview the rendered configuration files using Rails-over-HTTP, it is also essential to be able to write those same configuration files out to the filesystem. In another case, I had a background DRb process that needed to be able to render templated output to the filesystem. I had to go build a mock-controller and do some pretty unsavory things; all of that would have been obviated with acts_as_renderer.

Now, I can simply say:


class Server < ActiveRecord::Base
acts_as_renderer

def build_configuration
CLIENT_CONFIG_FILES.each do |f|
render_to_file("configs/#{f}", "#{config_dir}/#{f}.conf")
end
end
end


The render_to_file function renders the templates located in configs (under app/views by default) and writes them to the files specified in the config_dir; it's also smart enough to know that render_to_file is being called from a 'server' instance and sets @server accordingly. So my templates in configs are simply:


; Configuration Snippet for Server <%=@server.description%>

<%= render :partial => 'configs/queue', :collection => @server.queues %>


Please do think before using this plugin. It can be used for some seriously evil violations of good MVC design practice, and you are responsible for your own actions. However, this can also be used to make your existing designs *much* more robust and elegant, and I encourage you to use it where that is true.

It's ready to drop in. Everything is there, including tests. Enjoy!

NOTE: Version 1.0 only supported Rails 2.0; I just added version 1.01 which will work with either Rails 1.2.x or 2.0.x. Please feel free to ping me with any questions.

acts_as_renderer at RubyForge

16 comments:

dasil003 said...

What, no comments? This plugin is awesome!

Glad you made the MVC comment, because it's easy to dismiss the need for this. But for those of us who know what we're doing this is indispensable.

Rails controllers and views are made primarily for rendering to a browser, but HTML and other output often times needs to go other places. The alternatives to this plugin are HTML in models or rolling your own template system, either way pretty ugly.

Anyway, one comment on this plugin. We just installed it into our current project and had one problem. We are using some constants in our controllers, and since plugins are loaded before initializers, we get LoadErrors. Our solution is to put the plugin in lib/ and load it manually. Any ideas for a cleaner solution?

Dave Troy said...

This is an interesting problem. Not sure exactly what you're trying to accomplish in practice, but perhaps the constants could be put into environment.rb?

The acts_as_renderer plugin only subclasses ActionController:Base and should not need anything too peculiar. If you can tell me a bit more about specifically what you think you're adding that's breaking this (and where you're adding it) I might be able to come up with a tidy fix.

I've used this to great ends this week! I know I will be using this plugin myself for several projects in the future.

tigger said...

Thanks for taking the time to do this cleanly, it's just what we've been trying to do.

I've a small issue with using it though, which is that the application_helper doesn't seem to have been loaded and I can't quite figure out how to get that working.

Any ideas?

Rob Holland said...

Changing the self.render_string function to that listed below makes sure that the application helper is also loaded:

viewer = Class.new(ApplicationController)
view = Class.new(ActionView::Base)
view.send(:include, viewer.master_helper_module)
path = ActionController::Base.view_paths rescue ActionController::Base.view_root
view.new(path, assigns, viewer).render(template)

Dave Troy said...

Rob, your fix is exactly what I would suggest. I debated whether to base the viewer on ActionController::Base or ApplicationController, and the helper support is the obvious reason to go for the latter. I was trying to keep it as simple as possible.

I will put that into the next revision. Meantime you can just make the change as suggested. Thanks for the feedback everyone.

walt D said...

Hi Dave,

I'm trying to figure whether this plugin will help me do this:

I have an Invoices controller which renders a "show.html.erb" template (without layouts) - and I'd like this to go into a file instead of being presented to the user.

Right now I call a system command which curl -G http://localhost:3000/invoices/1 -o invoice_1.html - but my provider will not let me do this :(

Can I use this plugin - or do you know of some other way?

best regards,
Walther

walt D said...

Well - so I tried using your plugin - and failed miserably at doing so :(

I'm sure I'm to blame, myself - but perhaps you can point the finger more precisely at me :)

I added acts_as_renderer to my class Invoice < ActiveRecord::Base and threw a def build_invoice in there too.

When I tried using it - something in your plugin/init.rb blows up - and the result is that I get an error page when I try accessing any form new or form/edit in my app, with a note telling me that a copy of an ApplicationHelper was removed but still active.

I have a class ExtendedLayoutFormBuilder < ActionView::Helpers::FormBuilder

and that apparently gets hurt pretty bad by your plugin :(

I'm not sure what your plugin actually do, but somehow it breaks my extendedformbuilder

As I set out - I'm sure that I'm to blame, but all my layoutbuilder do is:


helpers = field_helpers +
%w(date_select datetime_select time_select collection_select) -
%w(label fields_for)

helpers.each do |name|
define_method name do |field, *args|
options = args.detect {|argument| argument.is_a?(Hash)} || {}
build_shell(field, options) do
super
end
end
end

def build_shell(field, options)
@template.capture do
locals = {
:element => yield,
:label => label(field, (options[:label] || "") ),
:lbl => options[:label],
:frmless => options[:frmless],# || nil,
:required => options[:required],# || nil,
:css => options[:class],
:instruction => options[:instruction]# || nil
}
if has_errors_on?(field)
locals.merge!(:error => error_message(field, options))
@template.render :partial => 'forms/field_with_errors',
:locals => locals
else
@template.render :partial => 'forms/field',
:locals => locals
end
end
end

def error_message(field, options)
if has_errors_on?(field)
errors = object.errors.on(field)
errors.is_a?(Array) ? errors.to_sentence : errors
else
''
end
end

def has_errors_on?(field)
!(object.nil? || object.errors.on(field).blank?)
end

walt D said...

Hi Dave Troy!

It's been awfully quite around the acts_as_renderer - and my lastest comment in particular :(

I'm still fighting this problem, and not really getting there.

See the stacktrace below (I am like skin-colour belt in rails-/ruby fu and just about knows how to read the stack trace)

Am I correct in 'decoding' the stack-trace to stumble upon some conflict in will_paginate - or?

I'd love to have your input on this one,

cheers,
walt

NameError in InvoicesController#index

undefined local variable or method `acts_as_renderer' for hash<Class:0x85fd950>

RAILS_ROOT: /Users/walther/Documents/RailsProjects/froeslev
Application Trace | Framework Trace | Full Trace

/Library/Ruby/Gems/1.8/gems/activerecord-2.0.2/lib/active_record/base.rb:1532:in `method_missing_without_paginate'
/Library/Ruby/Gems/1.8/gems/mislav-will_paginate-2.3.2/lib/will_paginate/finder.rb:164:in `method_missing'
app/models/invoice.rb:3
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:203:in `load_without_new_constant_marking'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:203:in `load_file'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:342:in `new_constants_in'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:202:in `load_file'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:94:in `require_or_load'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:248:in `load_missing_constant'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:453:in `const_missing'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:465:in `const_missing'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/inflector.rb:257:in `constantize'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/core_ext/string/inflections.rb:148:in `constantize'
app/controllers/invoices_controller.rb:2
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:203:in `load_without_new_constant_marking'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:203:in `load_file'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:342:in `new_constants_in'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:202:in `load_file'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:94:in `require_or_load'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:248:in `load_missing_constant'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:453:in `const_missing'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:465:in `const_missing'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/inflector.rb:257:in `constantize'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/core_ext/string/inflections.rb:148:in `constantize'
/Library/Ruby/Gems/1.8/gems/actionpack-2.0.2/lib/action_controller/routing.rb:1426:in `recognize'
/Library/Ruby/Gems/1.8/gems/rails-2.0.2/lib/webrick_server.rb:78:in `service'
/Library/Ruby/Gems/1.8/gems/rails-2.0.2/lib/commands/servers/webrick.rb:66
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:496:in `require'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:342:in `new_constants_in'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:496:in `require'
/Library/Ruby/Gems/1.8/gems/rails-2.0.2/lib/commands/server.rb:39

walt D said...

Dave :)

It's me again -

I do not know why it works this way around - but I put the self.render_string (with rob hollands fix) and render_to_string methods into my Invoice.rb class file

- and now I'm good to go

I know - it's not DRY, and I have to add it to every model that will print_something, but, hey, life isn't always what you'd expect, right :)

Anyways - thank you for sharing this!

cheers,
walt

Dave Troy said...

Yeah, sounds like an include or extend might do the same thing. Really not sure what the issue with will_paginate might be. If I revisit that code I will try to take a look and see what might be causing it. For now at least we have a documented workaround.

Jeff said...

Dave, have you gotten this to work with routes in the template? I'd like to include things like url_for in the template.

Johannes Fahrenkrug said...

Thank you so much! This was exactly what I needed! I hacked it a bit and added a "render_to_pdf" instance method to render a model with htmldoc. works like a charm, I'll blog about it next week.

Mr. Weir said...

Thank you very much, this plugin is most useful. I have to archive collections of articles to a third party. Which involves rendering them to HTML, without the site navigation, tarring them up and ftping them off.

Without this solution, it would be much more difficult.

Cody Brimhall said...

Brilliant! I have a CMS that renders models according to different partials depending on the viewer (atom, rss, web, etc.). This plugin makes it really easy to build and cache the views as soon as the content is updated. This has saved me a lot of trouble, so thanks!

Tomash said...

Cool stuff! Have you thought about moving it to Github? It would ease collaboration and forking, such a plugin could definitely use some more light and manpower.

Tomash said...

And yeah, it definitely needs some work as it doesn't play well with
a) rails 2.2+
b) other plugins (it loads application_controller before the plugins it could depend on are loaded)