ROXML Factory from Text Node Value
ROXML Basics
The ROXML library provides ruby objects with an XML binding that is easy to specify and round-trip. (Those familiar with ROXML may wish to skip to the punch line.)
class Library
include ROXML
xml_accessor :name
end
lib = Library.from_xml( xml ) # pass an IO or String containing the XML
lib.to_xml.to_s # render XML as a String
Easy.
Handle mixedCase element names such as phoneNumber by specifying a convention.
class Library
include ROXML
xml_convention do |s|
c = s.camelcase
c[0..0].to_s.downcase + c[1..-1].to_s
end
xml_accessor :phone_number
end
Changing the element name for an object is easy too.
class Library
include ROXML
xml_name 'mediaCenter' # yuck
end
We can nest objects:
class Book
include ROXML
xml_accessor :title
xml_accessor :author
end
class Library
include ROXML
xml_accessor :name
xml_accessor :books, :in => :books,
:as => [Book]
end
lib = Library.new
lib.name = 'Fullerton Public'
lib.books = []
a_book = Book.new
a_book.title = 'The Story About Ping'
a_book.author = 'Marjorie Flack'
lib.books << a_book
lib.to_xml.to_s
Rendered as so:
<library>
<name>Fullerton Public</name>
<books>
<book>
<title>The Story About Ping</title>
<author>Marjorie Flack</author>
....
The reverse works too.
Library.from_xml(xml).books.first.title # => The Story About Ping
ROXML also supports much more complex mappings of not just elements but attributes and not only to Arrays but also Hashes. The yardoc for Declarations is a helpful start.
An interesting problem
Now what if our system must also cope with special rules for journals, newspapers, eight-track tapes, laser discs, and the like? Since our XML is coming from a system designed by Acme Card Catalogs and Kitchen Appliances INC., we need to handle the following:
<library>
<media>
<item>
<medium>cassette-tape</medium>
<title>The Story About Ping</title>
....
So we need to look in the media element to know which class to load–and we want ROXML to handle it all for us.
Tricky Bits
In the following solution a base class, Medium, provides generic mappings for all media and a registration system for subclasses which define additional xml mappings.
class Medium
include ROXML
class << self
def register(medium_id = nil, a_class = self)
registry.store(medium_id || id_from_class, a_class)
end
def id_from_class
demod = ActiveSupport::Inflector.demodulize(self.name)
underscored = ActiveSupport::Inflector.underscore(demod)
dasherized = ActiveSupport::Inflector.dasherize(underscored)
end
def class_for(medium_id)
registry[medium_id]
end
def registry
unless Medium.instance_variable_defined?(:@registry)
Medium.instance_variable_set(:@registry, { })
end
Medium.instance_variable_get(:@registry)
end
private :registry
alias __original_from_xml from_xml
def from_xml(xml, *args)
if self == Medium
xml_node = ROXML::XML::Node.from(xml)
if xml_node.nil?
raise "Bad xml for action:\n\t#{xml}"
end
medium_id = xml_node.xpath('medium').first.text.strip
medium_class = @registry[medium_id] if defined?(@registry)
unless medium_class.nil?
medium_class.from_xml(xml_node, *args)
else
$stderr.puts "WARN: no medium for #{medium_id}."
__original_from_xml(xml, *args)
end
else
__original_from_xml(xml, *args)
end
end
end # class methods
xml_name 'medium' # even in subclasses
xml_convention do |s|
c = s.camelcase
c[0..0].to_s.downcase + c[1..-1].to_s
end
xml_accessor :title
xml_accessor :author
end
class CassetteTape < Medium
xml_accessor :read_by
end
class Book < Medium
xml_accessor :binding
end
class Library
xml_accessor :media,
:from => 'item',
:in => 'media',
:as => [Medium]
end
CassetteTape.register
Book.register
With this in place one may now read and write xml in the form:
<library>
<media>
<item>
<medium>cassette</medium>
<title>The Story About Ping</title>
<readBy>A. Reader</readBy>
</item>
<item>
<medium>book</medium>
<title>The Story About Ping</title>
<binding>hardcover</binding>
</item>
</media>
</library>
We now have objects with generic methods for all Media as well as data and behaviors specific to a medium.
lib = Library.from_xml(xml)
lib.media.each do |item|
puts "#{item.title} (#{item.class})"
end
The advantage of using this simple registration system is that we can easily substitute mocks and stubs for testing without a fancy dependency injection framework which is typically overkill in Ruby given the language’s power to reopen classes and the availability terrific mocking/stubbing libraries. For example, to test generic functionality without dependencies on specific media:
class FakeMedium < Medium; end
Medium.register('book', FakeMedium)
Library.from_xml( xml ) # with an xml document containing books,
# ROXML now parses the document with our mock medium
# instead of Book--which may not exist yet.
Sample code is available as a gist. Happy coding!
Thanks to Tricia Ball for pointing me to ROXML in the first place.