Of Badgers and XML: Custom XML Serialization in Rails

Comments

At the company where I work, we use billing software called ICOMS that has been around for ages. This is, of course, because of the commonly-held belief that only old software can possibly be enterprise-class. Well, that, and the fact that once you get yourself tied to a billing vendor it is extremely expensive to migrate to another one. Anyway, this particular billing software does have an API. Well, that's what they call it anyway. I'd call it something between a trip to the dentist's office and having a live badger loosed in my pants. Oh, right. The title said something about customizing your XML serialization in Rails. Click the link.

Status quo

So, consider for a moment the following XML representing an actual request to the existing API, to get the current bill for a customer.

    <icoms environment="ENV" password="password" userid="username" key="0000">
      <mac00029 slctsiteid="001" slctacntnmbr="123456789">
        <inl00146 slctprcscntrlnmbr="-1" slctstmntcode="1">
          <inl00155></inl00155>
          <inl00163 stmntlinetype="TS"></inl00163>
          <inl00150 slcttrnsctntype="A" trvrslevl="3"></inl00150>
          <inl00150 slcttrnsctntype="P" trvrslevl="3"></inl00150>
          <inl00150 slcttrnsctntype="R" trvrslevl="3"></inl00150>
          <inl00150 slcttrnsctntype="V" trvrslevl="3"></inl00150>
          <inl00150 slcttrnsctntype="Q" trvrslevl="3"></inl00150>
          <inl00150 slcttrnsctntype="O" trvrslevl="3"></inl00150>
          <inl00150 slcttrnsctntype="I" trvrslevl="3"></inl00150>
          <inl00152></inl00152>
        </inl00146>
      </mac00029>
    </icoms>

See, the ICOMS API works like this: There's an API gateway server, and it takes these XML requests, which include login credentials and the intended ICOMS environment (large companies will use more than one to cover various areas where they provide service) that the request should be processed against. You can see those as attributes on the ICOMS element above. The ICOMS element will include a "macro," which can contain one or more "inlines," which can themselves contain one or more inlines, and so on. All of these elements will be passed any relevant parameters by way of attributes on the element. With me so far? Good.

So, as you can see, those MACnnnnn and INLnnnnn bits above seem awfully arbitrary, like something I may have just made up as a placeholder. I didn't. That's the actual way you make requests to the API gateway. Aside from a handful of now deprecated sanely-named elements, any useful naming convention has been abandoned and replaced by these TB (or "trouser badger") elements, which must be cross-referenced in a 518-page PDF document to determine their intended function, what parameters they take, what elements they can contain, what elements they can return... You get the idea. You can see why I might want to create a simpler way to interact with the billing system.

The new hotness (or a step towards it, at least)

So, the end goal is to write a RESTful API that actually accepts a request to something like http://billing-api-sans-badgers/customers/001-123456789/current_bill and takes care of the rest, returning a reasonable representation of a bill.

Step one in this master plan is to write something that will let us build up a request to the existing API gateway, then serialize to XML in a way that the gateway understands when we need to actually send the request. A first instinct about tackling this problem might be to create a nested hash structure that represents the data and then just use the handy Rails Hash#to_xml mixin. However, Hash#to_xml will just end up serializing all keys as elements, and we need most keys to be treated as attributes. This is a perfect time to get our hands dirty with the guts of to_xml, Builder::XmlMarkup.

Badger badger badger

So, we know we have three basic building blocks to create a request our API gateway will understand: the main ICOMS element, Macros, and Inlines. Following the Rails to_xml convention, it might make sense to create three classes to represent these elements, and give each its own to_xml method. The nice thing about handling things this way is that later, if we want to create some convenience methods for working with this data, they have their own namespace to exist in already, and we can use writer methods to enforce constraints as we build the request if we wish to. None of that's necessary to illustrate today's topic, though, so let's keep it simple.

First, we'll create a class that models the ICOMS element that wraps the request and contains our macros and inlines. We'll call it Request.

      class Request
        attr_accessor :environment, :key, :userid, :password, :macro

        def initialize(attributes)
          @environment = attributes[:environment]
          @key = attributes[:key]
          @userid = attributes[:userid]
          @password = attributes[:password]
          if attributes.has_key?(:macro)
            @macro = Macro.new(*attributes.delete(:macro))
          end
        end
      end

You'll notice I added a convenience feature for initialization of the Request class, so that if a :macro "attribute" is passed in on initialization, an object of class Macro will be created with the contents of that "attribute" as initialization parameters. Of course, we now have to model the Macro element:

    class Macro
        attr_accessor :element, :attributes, :inlines
        def initialize(element, attributes = {})
          @element = element
          @attributes = attributes || {}
          @inlines = []

          if att_inlines = self.attributes.delete(:inlines)
            att_inlines.to_a.each do |inline|
              @inlines << Inline.new(*inline)
            end
          end
        end
      end

The only interesting deviation from our Request class is that we have an array called inlines, since a Macro can contain more than one inline. If we're passed :inlines as an attribute, we delete it and instead populate the inlines array with Inline objects. Which brings us to the Inline class:

      # Inlines can contain other inlines.
      class Inline
        attr_accessor :element, :attributes, :inlines
        def initialize(element, attributes = {})
          @element = element
          @attributes = attributes || {}
          @inlines = []

          if att_inlines = self.attributes.delete(:inlines)
            att_inlines.to_a.each do |inline|
              @inlines << Inline.new(*inline)
            end
          end
        end
      end

Lovely. We have now modeled what we know about a valid TB ICOMS API request. Of course, that doesn't get us an XML version just yet.

Class, serialize thyself.

Earlier, I mentioned we'd be using Builder::XmlMarkup to create our own custom to_xml methods for these spiffy new classes we've put together. If you're not familiar with Builder::XmlMarkup, go ahead and check it out in the Rails API documentation now. While you're at it, you may want to look at the example for overriding ActiveRecord's to_xml method (it's at the end). I'll be here when you get back.

Back already? Great. Let's get started.

First up, we'll add a to_xml method to Request:

      class Request
      (...)
        def to_xml(options = {})
          options[:indent] ||= 2
          xml = options[:builder] ||= Builder::XmlMarkup.new(:indent => options[:indent])
          xml.instruct! unless options[:skip_instruct]
          xml.ICOMS(:ENVIRONMENT => self.environment,
                    :KEY => self.key,
                    :USERID => self.userid,
                    :PASSWORD => self.password) {
            self.macro.to_xml(options.merge!(:dasherize => false, :skip_instruct => true))
          }
        end
      end

In Request, we know what attributes we have ahead of time, because there's only one set of possibilities to deal with, so we call them by name. We build the ICOMS element along with those attributes, then ask our Macro to serialize itself inside the ICOMS element, being sure to add a couple of extra builder options, to prevent underscores from becoming dashes, and avoid a second XML instruction being added:

      class Macro
      (...)
        def to_xml(options = {})
          options[:indent] ||= 2
          xml = options[:builder] ||= Builder::XmlMarkup.new(:indent => options[:indent])
          xml.instruct! unless options[:skip_instruct]
          if self.inlines.empty?
            xml.tag!(self.element, self.attributes)
          else
            xml.tag!(self.element, self.attributes) {
              self.inlines.each do |inline|
                inline.to_xml(options.merge!(:dasherize => false, :skip_instruct => true))
              end
            }
          end
        end
      end

With a Macro, we have one new thing to deal with. We may or may not have Inlines in the Macro, and we'd prefer a self-closing element if there are no Inlines to serialize. Builder::XmlMarkup will self-close the element if no block is passed, so that solves that.

Wrapping it up

Now, at this point, you might think we'd be going ahead with writing a to_xml for our Inline class. You'd be wrong, though. Why? Well, those readers who are more advanced in code fu and probably don't need to read this write-up (but are doing so anyway because it beats actually working and reading about coding sort of counts as self-education even if you already knew what you're reading about... right?) would have likely noticed a way to make this implementation more DRY way back when I described what a TB ICOMS API request looks like.

To recap:

  • There's an ICOMS element (Request) with a few specific connection parameters passed as attributes.
  • Requests contain a Macro, which represents some code to run on the server along with parameters passed to this code.
  • Macros are containers for Inlines, which represent some code to run on the server, along with parameters passed to this code.
  • Inlines are containers for other inlines, which represent...

Wait a minute. So, in an abstract sense, Macros are the same as Inlines! That would explain why the code for modeling and serializing them is exactly the same. So, rather than continue down this rather silly and repetitive path, let's quickly refactor our code, and we'll end up with an InlineContainer class, which both Macro and Inline derive from. This reduces our Macro and Inline classes down to:

      class Macro < InlineContainer
      end

      class Inline < InlineContainer
      end

Wow. That's so zen. We'll bundle it up into an Icoms namespace with a module, and here we have the finished product: ICOMS Request Generator

Now, if we decide to add methods to generate different types of Macros and Inlines with a more convenient syntax, we have a nice blank canvas to work with. Or, we might (I did, actually) choose to create an API model within our Icoms namespace to do the heavy lifting of creating different types of requests, tracking what server IP they should be submitted to, sending the HTTP request, and so on.

Either way, now that we've gotten a representation of the insane vendor-supplied API request out of the way that knows how to serialize itself, we've got one less badger in our pants.

comments powered by Disqus