20 XML to Object Mapper

Since version 2.0 Spicelib contains a small but powerful and flexibel XML-to-Object Mapper. It allows you to map from XML to AS3 classes - in both directions. It comes with builtin mappers which cover the most common use cases like mapping properties of AS3 classes to XML attributes or child elements. But it is easily extensible to add your custom mappers for some of your XML elements and combine them with the builtin ones. In version 2.3 support for metadata configuration was added, leading to a significantly easier setup.

20.1 Usage Example

Let's start with a simple example. Consider this XML structure:

<order>
    <book 
        isbn="0942407296" 
        page-count="256"
        title="Rain"
        author="Karen Duve"
    />
    <book 
        isbn="0953892201" 
        page-count="272"
        title="Perfume"
        author="Patrick Suskind"
        comment="Special Offer"
    />    
</order>

Now we create two classes that this XML structure should map to:

public class Order {

    [ChoiceType("com.foo.Book")]
    public var books:Array;
    
}
public class Book {

    [Required]
    public var isbn:String;
    
    [Required]
    public var pageCount:int;
    
    [Required]
    public var title:String;
    
    [Required]
    public var author:String;
    
    public var comment:String;
    
}

It should be obvious how XML elements and attributes are supposed to map to these two classes and their properties. The only details that probably need a bit of explanation are the [Required] and [ChoiceType] metadata tags. The former gives hint to the validator. So for the book element only the comment attribute is optional. If any of the other 4 attributes would be missing the mapping operation will fail. For more details see 20.9 Validation.

The [ChoiceType] metadata tag lists the classes that are permitted as elements in the Array. All XML elements that map to the Book class or any subtype are valid child elements in this case. If this tag would be omitted, all tags would be allowed. For most mapping types there are sensible defaults. For simple property types like String and int the default is to map them to XML attributes, the same way as if you would add the [Attribute] metadata tag to the property declaration.

Next we create the mapper that is responsible for transforming this structure;

var mapper:XmlObjectMapper = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Order)
    .mappedClasses(Book)
    .build();    

Here we are using the DSL for the mapper setup introduced with version 2.3. First we specify that our elements are unqualified, support for namespaces is also included of course. Then we have to specify the root element of our mapping and finally all other mapped classes used in any nested tags (in this case only one - Book). Metadata will be processed on the specified classes to look for any custom mappings.

The mapper we created is now ready to use. This is how we would map from XML to objects:

var xml:XML = ...;
var order:Order = mapper.mapToObject(xml) as Order;

And this is how we'd map from objects to XML:

var order:Order = ...;
var xml:XML = mapper.mapToXml(order);

20.2 Metadata Configuration and Defaults

Although the mappings support metadata configuration the most convenient way to set up your mappings is to rely on the defaults (configuration by convention). A third option to determine how a particular property should map to XML is programmatic setup.

Configuration Precedence

For each property of a class the mapper determines if and how it should be mapped by checking and applying configuration options in the following order:

Default Behaviours

The default behaviour if no programmatic or metadata configuration was applied is as follows:

Naming Conventions

As you can see in our first example in this chapter, we were mapping the pageCount property to the page-count XML attribute. Per default the mapper always translates camel-case ActionScript names into dash notation which is more common in XML. So using pageCount as the attribute name in XML would actually give you an error.

This is just the default behavior though and you can easily apply your own naming strategy:

var mapper:XmlObjectMapper = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Order)
    .defaultNamingStrategy(new MyNamingStrategy());
    .mappedClasses(Book)
    .build();    

NamingStrategy is a trivial interface with just a single method that translates an ActionScript name to an XML name.

The NamingStrategy is useful to change the convention globally. But you can also explicitly specify the name for a single element or attribute. See the following section on 20.3 Mapping Attributes for an example on how to do that for a property that maps to an XML attribute. If you want to specify the matching XML element name for a class name, you can do that with the XmlMapping tag:

[XmlMapping(elementName="product")]
public class ProductModel 

20.3 Mapping Attributes

Properties with a simple type like String, int, Boolean, Class or Date can be mapped to attributes:

public class Song {

    public var year:int;
    public var title:String;
    public var artist:String;
    
}
<song
    year="1989" 
    title="Monkey Gone To Heaven"
    artist="Pixies"
/>

Since mapping to XML attributes is the default for simple properties, no metadata configuration is required on the properties in this case. You can use the [Attribute] metadata tag whenever the property type is not one of the simple types automatically mapped to attributes (see 20.2 Metadata Configuration and Defaults) or when the name of the attribute does not match the property name:

[Attribute("song-title")]
public var title:String;

Otherwise the setup for such a mapper is straightforward:

var xml:XML = ...;
var song:Song = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Song)
    .build()
    .mapToObject(xml) as Song;    

20.4 Mapping Child Text Nodes

Properties with a simple type like String, int, Boolean, Class or Date can also be mapped to child text nodes, a mechanism very similar to mapping to attributes:

public class Song {

    public var year:int;
    public var title:String;
    public var artist:String;
    
}
<song>
    <year>1989</year> 
    <title>Monkey Gone To Heaven</title>
    <artist>Pixies</artist>
</song>

The default for simple property types is to map them to XML attributes. Thus, for switching to text nodes in child elements you'd usually have to use explicit metadata configuration then:

[ChildTextNode]
public var year:int;

But if all or most of your XML elements follow this pattern, you can also switch the default globally and thus avoid any metadata configuration:

var xml:XML = ...;
var song:Song = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Song)
    .defaultSimpleMappingType(SimpleMappingType.CHILD_TEXT_NODE)
    .build()
    .mapToObject(xml) as Song;    

20.5 Mapping Text Nodes

This is different from mapping to child text nodes. It maps a property to the text node that belongs to the same element. Since this can only apply for a single property it is often combined with attribute mapping like in the following example:

public class Song {

    public var year:int;

    public var artist:String;

    [TextNode]
    public var title:String;
    
}
<song year="2000" artist="Goldfrapp">Felt Mountain</song>

This is how the mapping for the example above would be initialized:

var xml:XML = ...;
var song:Song = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Song)
    .build()
    .mapToObject(xml) as Song;    

20.6 Mapping Child Elements

Mapping to child elements allows you to build a hierarchy of nested mappers like shown in the usage example in the beginning of this chapter.

public class Album {

    public var year:int;
    public var title:String;
    public var artist:String;
    
    [ChoiceType("com.foo.Song")]
    public var songs:Array;
    
}

public class Song {

    public var duration:String;
    public var title:String;
    
}
<album year="2000" artist="Goldfrapp" title="Felt Mountain">
    <song title="Lovely Head" duration="3:50"/>
    <song title="Pilots" duration="4:30"/>
    <song title="Deer Stop" duration="4:07"/>
    <song title="Utopia" duration="4:18"/>
</album>

In this example the song child elements will be mapped into the songs property of the Album class. Again we could also use the defaults, but that would allow any tag to be nested inside the album tag. That's why we explicitly specify the permitted type (including subtypes) with the [ChoiceType] metadata tag.

This is how you would set up such a mapper:

var xml:XML = ...;
var album:Album = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Album)
    .mappedClasses(Song)
    .build()
    .mapToObject(xml) as Album;    

20.7 Mapping disjointed Choices

In the preceding section we mapped to child elements through the use of the [ChoiceType] tag, which allows us to specify the permitted types for the elements of an Array in a polymorphic way. Sometimes though this is not sufficient if the permitted types for an Array are not part of a distinct type hierarchy. In such a case a choice with a string identifier can be used, so that you can explicitly specify which classes are permitted.

public class Order {

    [ChoiceId("products")]
    public var products:Array;
    
}

public class Album {

    public var artist:String;
    public var title:String;
    public var duration:String;
    
}

public class Book {

    public var author:String;
    public var title:String;
    public var pageCount:String;  

}
<order>
    <album artist="Goldfrapp" title="Felt Mountain"  duration="38:50"/>
    <album artist="Unkle" title="Never, Never, Land"  duration="49:27"/>
    <book author="Karen Duve" title"Rain" pageCount="256"/>
    <book author="Judith Hermann" title"Summerhouse, Later" pageCount="224"/>
</order>

In the mapper setup we then have to list all classes that should be associated with that choice id:

var xml:XML = ...;
var order:Order = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Order)
    .choiceId("products", Album, Book)
    .build()
    .mapToObject(xml) as Order;    

20.8 Working with Namespaces

So far we only used unqualified XML elements in all examples to keep them simple. Therefor we always used XmlObjectMappings.forUnqualifiedElements() to start the mapper setup. But of course the mapper fully supports namespaces, too. If you only use a single namespace the overall setup is quite similar to that for unqualified elements. Consider the example from the preceding section, just adding a namespace to all elements:

<order xmlns="http://www.mynamespace.com">
    <album artist="Goldfrapp" title="Felt Mountain"  duration="38:50"/>
    <album artist="Unkle" title="Never, Never, Land"  duration="49:27"/>
    <book author="Karen Duve" title"Rain" pageCount="256"/>
    <book author="Judith Hermann" title"Summerhouse, Later" pageCount="224"/>
</order>

To set up the corresponding mapper we then have to explicitly specify the namespace:

var xml:XML = ...;
var order:Order = XmlObjectMappings
    .forNamespace("http://www.mynamespace.com")
    .withRootElement(Order)
    .choiceId("products", Album, Book)
    .build()
    .mapToObject(xml) as Order;    

Finally, in some cases you may wish to work with multiple namespaces in the same document. Consider you'd want to add a separate namespace for software products to the example above:

<order 
    xmlns="http://www.mynamespace.com" 
    xmlns:sf="http://www.mynamespace.com/software">
    <album artist="Goldfrapp" title="Felt Mountain"  duration="38:50"/>
    <album artist="Unkle" title="Never, Never, Land"  duration="49:27"/>
    <book author="Karen Duve" title"Rain" pageCount="256"/>
    <book author="Judith Hermann" title"Summerhouse, Later" pageCount="224"/>
    <sf:software product="PageDesigner" version="4.0"/>
</order>

For such a scenario we'd set up separate groups of mappings for each namespace and then merge them:

var xml:XML = ...;
var softwareNS:XmlObjectMappings = XmlOhjectMappings
    .forNamespace("http://www.mynamespace.com/software")
    .withoutRootElement()
    .choiceId("products", Software);
    
var order:Order = XmlObjectMappings
    .forNamespace("http://www.mynamespace.com")
    .withRootElement(Order)
    .choidId("products", Album, Book)
    .mergedMappings(softwareNS)
    .build()
    .mapToObject(xml) as Order;    

As you see such a merged namespace often does not need a root element and you also do not need to call build on the namespaces to be merged. You can also see that merging namespaces also means to merge all choices with the same id. This way the choice with the id products in our example can contain child elements from different namespaces.

20.9 Validation

Like shown in 20.1 Usage Example you can place [Required] metadata on properties so that the mapper throws an Error if the attribute or child element that the property is mapped to is not present in XML. This section provides some more detail on the exact semantics of this feature.

Validating single valued properties

When a property is single valued, either with a simple type that maps to an attribute or a text node or typed to a class that maps to a child element, the validation process includes the following checks:

Validating Array properties

Array properties cannot be mapped to attributes (as multiple occurences of the same attribute in a single element are not possible). If they are mapped to child text nodes or child elements, the validation process includes the following checks:

Ignoring properties

Sometimes a class may contain properties that should not be mapped to XML. You can exclude individual properties from the mapping with the [Ignore] tag:

[Ignore]
public var somethingUnusual:String;

Without this tag (and without any other mapping tag on that property) the mapper would create the default mappings for that property.

Ignoring xml elements and attributes

In some scenarios you may not be able to create strict mappings as the XML may contain child elements or attributes which are not relevant for the client side and should be ignored. To do that for a particular mapped class you can add the [XmlMapping] metadata to the class declaration:

[XmlMapping(ignoreUnmappedAttributes="true", ignoreUnmappedChildren="true")]
public class Order {

Without these settings any attribute or child element unknown to the mapper would lead to an Error.

20.10 Programmatic Mapper Setup

There may be edge cases where neither the default settings nor the available metadata tags provide the required behaviour for a particular property. In these cases you can mix the default behaviour with explicit programmatic setup:

var xml:XML = ...;
var mappings:XmlObjectMappings = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Order);
mappings    
    .newMapperBuilder(Album)
        .mapToAttribute("title")
        .mapToChildTextNode("artist");
var order:Order = mappings
    .choidId("products", Album, Book)
    .build()
    .mapToObject(xml) as Order;    

In the example above we set up the mapper for the Album class programmatically. The mapper will still look for metadata or apply the default behaviour for all properties of the Album class that were not explicitly mapped. This would even allow to use metadata in the class and override it in specific scenarios through programmatic setup.

20.11 Custom Mapping Tags

Similarly like with the metadata tags for Parsley configuration, you can create additional mapping tags in case the builtin ones are not sufficient. For this you'd simply have to implement the MetadataMapperDecorator interface:

public interface MetadataMapperDecorator {

    function decorate (builder:MetadataMapperBuilder) : void;
	
}

For each metadata tag on any property of a mapped class the decorate method would be invoked. For full examples you may browse the existing implementations, as all builtin tags use this hook, too. They reside in the package org.spicefactory.lib.xml.mapper.metadata.

Like with the Parsley metadata tags, make sure that they meet the following requirements:

20.12 Creating Custom Mappers

Finally there may even be a scenario where none of the available mapping types are sufficient. In this case you can create a custom mapper implementing the XmlObjectMapper element from scratch.

The interface is quite simple:

public interface XmlObjectMapper {
	
	function get objectType () : ClassInfo;

	function get elementName () : QName;
	
	function mapToObject (element:XML, context:XmlProcessorContext) : Object;

	function mapToXml (object:Object, context:XmlProcessorContext) : XML;
	
}

It specifies the class and the XML element name that should be mapped and then two methods for mapping in both directions. In case you have a large and complex XML structure where you can use existing property mappers for most of the tags, but need a custom mapper for a single tag, you can combine the builtin mappers with your custom one:

var xml:XML = ...;
var order:Order = XmlObjectMappings
    .forUnqualifiedElements()
    .withRootElement(Order)
    .customMapper(new MyCustomMapper())
    .choidId("products", Album, Book)
    .build()
    .mapToObject(xml) as Order;