Search code examples
ruby-on-railsrubyxmlxml-builder

Ruby on Rails: Using XML Builder Partials


Partials in XML builder are proving to be non-trivial.

After some initial Google searching, I found the following to work, although it's not 100%

 xml.foo do
     xml.id(foo.id)
     xml.created_at(foo.created_at)
     xml.last_updated(foo.updated_at)
     foo.bars.each do |bar|
         xml << render(:partial => 'bar/_bar', :locals => { :bar => bar })
     end
 end

this will do the trick, except the XML output is not properly indented. the output looks something similar to:

<foo>
  <id>1</id>
  <created_at>sometime</created_at>
  <last_updated>sometime</last_updated>
<bar>
  ...
</bar>
<bar>
  ...
</bar>
</foo>

The <bar> element should align underneath the <last_updated> element, it is a child of <foo> like this:

<foo>
  <id>1</id>
  <created_at>sometime</created_at>
  <last_updated>sometime</last_updated>
  <bar>
    ...
  </bar>
  <bar>
    ...
  </bar>
</foo>

Works great if I copy the content from bar/_bar.xml.builder into the template, but then things just aren't DRY.


Solution

  • There is unfortunately not a straight-forward solution to this. When looking at the code that ActionPack will initialize the Builder object with then the indent size is hard-coded to 2 and the margin size is not set. Its a shame that there is no mechanism to override this at present.

    The ideal solution here would be a fix to ActionPack to allow these options to be passed to the builder but this would require some time investment. I have 2 possible fixes for you. Both dirty you can take your pick which feels less dirty.

    Modify the rendering of the partial to render to a string and then do some Regex on it. This would look like this

    _bar.xml.builder

    xml.bar do
      xml.id(bar.id)
      xml.name(bar.name)
       xml.created_at(bar.created_at)
       xml.last_updated(bar.updated_at)
    end
    

    foos/index.xml.builder

    xml.foos do
      @foos.each do |foo|
        xml.foo do
          xml.id(foo.id)
          xml.name(foo.name)
          xml.created_at(foo.created_at)
          xml.last_updated(foo.updated_at)
          xml.bars do
            foo.bars.each do |bar|
              xml << render(:partial => 'bars/bar', 
                     :locals => { :bar => bar } ).gsub(/^/, '      ')
            end
          end
        end
      end
    end
    

    Note the gsub at the end of render line. This produces the following results

    <?xml version="1.0" encoding="UTF-8"?>
    <foos>
      <foo>
        <id>1</id>
        <name>Foo 1</name>
        <created_at>2010-06-11 21:54:16 UTC</created_at>
        <last_updated>2010-06-11 21:54:16 UTC</last_updated>
        <bars>
          <bar>
            <id>1</id>
            <name>Foo 1 Bar 1</name>
            <created_at>2010-06-11 21:57:29 UTC</created_at>
            <last_updated>2010-06-11 21:57:29 UTC</last_updated>
          </bar>
        </bars>
      </foo>
    </foos>
    

    That is a little hacky and definitely quite dirty but has the advantage of being contained within your code. The next solution is to monkey-patch ActionPack to get the Builder instance to work the way we want

    config/initializers/builder_mods.rb

    module ActionView
      module TemplateHandlers
        class BuilderOptions
          cattr_accessor :margin, :indent
        end
      end
    end
    
    module ActionView
      module TemplateHandlers
        class Builder < TemplateHandler
    
          def compile(template)
            "_set_controller_content_type(Mime::XML);" +
              "xml = ::Builder::XmlMarkup.new(" +
              ":indent => #{ActionView::TemplateHandlers::BuilderOptions.indent}, " +
              ":margin => #{ActionView::TemplateHandlers::BuilderOptions.margin});" +
              "self.output_buffer = xml.target!;" +
              template.source +
              ";xml.target!;"
          end
        end
      end
    end
    
    ActionView::TemplateHandlers::BuilderOptions.margin = 0
    ActionView::TemplateHandlers::BuilderOptions.indent = 2
    

    This creates a new class at Rails initialisation called BuilderOptions whose sole purpose is to host 2 values for indent and margin (although we only really need the margin value). I did try adding these variable as class variables directly to the Builder template class but that object was frozen and I couldn't change the values.

    Once that class is created we patch the compile method within the TemplateHandler to use these values.

    The template then looks as follows :-

    xml.foos do
      @foos.each do |foo|
        xml.foo do
          xml.id(foo.id)
          xml.name(foo.name)
          xml.created_at(foo.created_at)
          xml.last_updated(foo.updated_at)
          xml.bars do
            ActionView::TemplateHandlers::BuilderOptions.margin = 3    
            foo.bars.each do |bar|
              xml << render(:partial => 'bars/bar', :locals => { :bar => bar } )
            end
            ActionView::TemplateHandlers::BuilderOptions.margin = 0
          end
        end
      end
    end
    

    The basic idea is to set the margin value to the indentation level that we are at when rendering the partial. The XML generated is identical to that shown above.

    Please do not copy/paste this code in without checking it against your Rails version to ensure that they are from the same codebase. (I think the above is 2.3.5)