Skip to content

Latest commit

 

History

History
3211 lines (2543 loc) · 81.7 KB

README.adoc

File metadata and controls

3211 lines (2543 loc) · 81.7 KB

LutaML Ruby modeller

GitHub Stars GitHub Forks License Build Status RubyGems Version

Purpose

Lutaml::Model is a lightweight library for serializing and deserializing Ruby objects to and from various formats such as JSON, XML, YAML, and TOML. It uses an adapter pattern to support multiple libraries for each format, providing flexibility and extensibility for your data modeling needs.

Note
Lutaml::Model is designed to be mostly compatible with the data modeling API of Shale, an impressive Ruby data modeller. Lutaml::Model is meant to address advanced needs not currently addressed by Shale.
Note
Instructions on how to migrate from Shale to Lutaml::Model are provided in Migration steps from Shale.

Features

  • Define models with attributes and types

  • Serialize and deserialize models to/from JSON, XML, YAML, and TOML

  • Support for multiple serialization libraries (e.g., toml-rb, tomlib)

  • Configurable adapters for different serialization formats

  • Support for collections and default values

  • Custom serialization/deserialization methods

  • XML namespaces and mappings

Data modeling in a nutshell

Data modeling is the process of creating a data model for the data to be stored in a database or used in an application. It helps in defining the structure, relationships, and constraints of the data, making it easier to manage and use.

Lutaml::Model simplifies data modeling in Ruby by allowing you to define models with attributes and serialize/deserialize them to/from various serialization formats seamlessly.

The Lutaml::Model data modelling approach is as follows:

Modeling relationships of a LutaML Model
                       Lutaml Model
                             │
                    Has many attributes
                             │
                             ▼
                        Attribute
                             │
                        Has type of
                             │
                  ┌──────────┴──────────┐
                  │                     │
                Model              Value (Leaf)
                  │                     │
       Has many attributes    Contains one basic value
                  │                     │
          ┌───────┴─────┐        ┌──────┴──────┐
          │             │        │             │
        Model      Value (Leaf)  String       Integer
          │                      Date         Boolean
          │                      Time         Float
  Has many attributes            ...          ...
          │
          ▼
  (Recursive pattern continues...)
Example 1. Example of LutaML Model instance with assigned values
Studio (Model)
 ├── name (Value: String) = "Pottery Studio"
 ├── address (Model)
 │    ├── street (Value: String) = "123 Clay St"
 │    ├── city (Value: String) = "Ceramics City"
 │    └── postcode (Value: String) = "12345"
 ├── established (Value: Date) = 2020-01-01
 └── kilns (Model)
      ├── count (Value: Integer) = 3
      └── temperature (Value: Float) = 1200.0
Modeling relationships of a LutaML Model to serialization models
    Core Model                  Serialization Model
    ==========                  ===================
        │                           (mapping)
        │                               │
        ▼                               ▼
      Model                         XML Model
        │                               │
  ┌─────┴─────┐                  ┌──────┴──────┐
  │           │                  │             │
Models   Value Types ────┬──►  Models     Value Types
  │           │          │       │             │
  │           │          │       │             │
  │    ┌──────┴──┐       │  ┌────┴────┐      ┌─┴─┐
  │    │         │       │  │         │      │   │
  │   String  Integer    │ Element  Value  xs:string
  │   Date    Float      │ Attribute Type  xs:date
  │   Time    Boolean    │                 xs:boolean
  │                      │                 xs:anyURI
  └──────┐               │
         │               │         JSON Model
    Contains             │              │
    more Models          │       ┌──────┴──────┐
    (recursive)          │       │             │
                         └──►  Models     Value Types
                                 │             │
                                 │             │
                            ┌────┴────┐      ┌─┴─┐
                            │         │      │   │
                           object  array  number string
                           value          boolean null
Example 2. Example of LutaML Model instance transformed into a serialization model and serialized to JSON
Studio (Core Model)          JSON Model                Serialized JSON
    │                            │                            │
    ▼                            ▼                            ▼
name: "Studio 1"     ┌─► { "name": "...",         {
address:             │     "address": {             "name": "Studio 1",
  ├── street: "..."  │       "street": "...",       "address": {
  └── city: "..."    │       "city": "..."    ──►     "street": "...",
kilns:               │     },                         "city": "..."
  ├── count: 3       │    "kilnsCount": ...,        },
  └── temp: 1200     │    "kilnsTemp": ...          "kilnsCount": 3,
                     └─► }                          "kilnsTemp": 1200
                                                  }

Installation

Add this line to your application’s Gemfile:

gem 'lutaml-model'

And then execute:

bundle install

Or install it yourself as:

gem install lutaml-model

Data model class

Definition

General

There are two ways to define a data model in Lutaml::Model:

  • Inheriting from the Lutaml::Model::Serializable class

  • Including the Lutaml::Model::Serialize module

Definition through inheritance

The simplest way to define a model is to create a class that inherits from Lutaml::Model::Serializable.

The attribute class method is used to define attributes.

require 'lutaml/model'

class Kiln < Lutaml::Model::Serializable
  attribute :brand, :string
  attribute :capacity, :integer
  attribute :temperature, :integer
end

Definition through inclusion

If the model class already has a super class that it inherits from, the model can be extended using the Lutaml::Model::Serialize module.

require 'lutaml/model'

class Kiln < SomeSuperClass
  include Lutaml::Model::Serialize

  attribute :brand, :string
  attribute :capacity, :integer
  attribute :temperature, :integer
end

Comparison

A Serialize / Serializable object can be compared with another object of the same class using the == operator. This is implemented through the ComparableModel module.

Two objects are considered equal if they have the same class and all their attributes are equal. This behavior differs from the typical Ruby behavior, where two objects are considered equal only if they have the same object ID.

Note
Two Serialize objects will have the same hash value if they have the same class and all their attributes are equal.
> a = Kiln.new(brand: 'Kiln 1', capacity: 100, temperature: 1050)
> b = Kiln.new(brand: 'Kiln 1', capacity: 100, temperature: 1050)
> a == b
> # true
> a.hash == b.hash
> # true

Defining attributes

Supported attribute value types

General types

Lutaml::Model supports the following attribute types, they can be referred by a string, a symbol, or their class constant.

Every type has a corresponding Ruby class and a serialization format type.

Syntax:

attribute :name_of_attribute, {symbol | string | class}
Table 1. Mapping between Lutaml::Model::Type classes, Ruby equivalents and serialization format types
Lutaml::Model::Type Ruby class XML JSON YAML Example value

:string

String

xs:string

string

string

"text"

:integer

Integer

xs:integer

number

integer

42

:float

Float

xs:decimal

number

float

3.14

:boolean

TrueClass/FalseClass

xs:boolean

boolean

boolean

true, false

:date

Date

xs:date

string

string

2024-01-01 (JSON/YAML "2024-01-01")

:time_without_date

Time

xs:time

string

string

"12:34:56"

:date_time

DateTime

xs:dateTime

string

string

"2024-01-01T12:00:00+00:00"

:time

Time

xs:dateTime

string

string

"2024-01-01T12:00:00+00:00"

:decimal (optional)

BigDecimal

xs:decimal

number

float

123.45

:hash

Hash

complex element

object

map

{key: "value"}

Example 3. Defining attributes with supported types via symbol, string and class
class Studio < Lutaml::Model::Serializable
  # The following are equivalent
  attribute :location, :string
  attribute :potter, "String"
  attribute :kiln, :string
end
> s = Studio.new(location: 'London', potter: 'John Doe', kiln: 'Kiln 1')
> # <Studio:0x0000000104ac7240 @location="London", @potter="John Doe", @kiln="Kiln 1">
> s.location
> # "London"
> s.potter
> # "John Doe"
> s.kiln
> # "Kiln 1"

Decimal type

The Decimal type is an optional type that is disabled by default.

Note
The reason why the Decimal type is disalbed by default is that the BigDecimal class became optional to the standard Ruby library from Ruby 3.4 onwards. The Decimal type is only enabled when the bigdecimal library is loaded.

The following code needs to be run before using (and parsing) the Decimal type:

require 'bigdecimal'

If the bigdecimal library is not loaded, usage of the Decimal type will raise a Lutaml::Model::TypeNotSupportedError.

Custom type

A custom class can be used as an attribute type. The custom class must inherit from Lutaml::Model::Type::Value or a class that inherits from it.

A class inheriting from the Value class carries the attribute value which stores the one-and-only "true" value that is independent of serialization formats.

The minimum requirement for a custom class is to implement the following methods:

self.cast(value)

Assignment of an external value to the Value class to be set as value. Casts the value to the custom type.

self.serialize(value)

Serializes the custom type to an object (e.g. a string). Takes the internal value and converts it into an output suitable for serialization.

Example 4. Using a custom value type to normalize a postcode with minimal methods
class FiveDigitPostCode < Lutaml::Model::Type::String
  def self.cast(value)
    value = value.to_s if value.is_a?(Integer)

    unless value.is_a?(::String)
      raise Lutaml::Model::InvalidValueError, "Invalid value for type 'FiveDigitPostCode'"
    end

    # Pad zeros to the left
    value.rjust(5, '0')
  end

  def self.serialize(value)
    value
  end
end

class Studio < Lutaml::Model::Serializable
  attribute :postcode, FiveDigitPostCode
end

Serialization of custom types

The serialization of custom types can be made to differ per serialization format by defining methods in the class definitions. This requires additional methods than the minimum required for a custom class (i.e. self.cast(value) and self.serialize(value)).

This is useful in the case when different serialization formats of the same model expect differentiated value representations.

The methods that can be overridden are named:

self.from_{format}(serialized_string)

Deserializes a string of the serialization format and returns the object to be assigned to the Value class' value.

to_{format}

Serializes the object to a string of the serialization format.

The {format} part of the method name is the serialization format in lowercase (e.g. json, xml, yaml, toml).

Example 5. Using custom serialization methods to handle a high-precision date-time type

Suppose in XML we handle a high-precision date-time type that requires custom serialization methods, but other formats such as JSON do not support this type.

For instance, in the normal DateTime class, the serialized string is 2012-04-07T01:51:37+02:00, and the high-precision format is 2012-04-07T01:51:37.112+02:00.

We create HighPrecisionDateTime class is a custom class that inherits from Lutaml::Model::Type::DateTime.

class HighPrecisionDateTime < Lutaml::Model::Type::DateTime
  # Inherit the `self.cast(value)` and `self.serialize(value)` methods
  # from Lutaml::Model::Type::DateTime

  # The format looks like this `2012-04-07T01:51:37.112+02:00`
  def self.from_xml(xml_string)
    ::DateTime.parse(xml_string)
  end

  # The %L adds milliseconds to the time
  def to_xml
    value.strftime('%Y-%m-%dT%H:%M:%S.%L%:z')
  end
end

class Ceramic < Lutaml::Model::Serializable
  attribute :kiln_firing_time, HighPrecisionDateTime
  xml do
    root 'ceramic'
    map_element 'kilnFiringTime', to: :kiln_firing_time
    # ...
  end
end

An XML snippet with the high-precision date-time type:

<ceramic>
  <kilnFiringTime>2012-04-07T01:51:37.112+02:00</kilnFiringTime>
  <!-- ... -->
</ceramic>

When loading the XML snippet, the HighPrecisionDateTime class will be used to parse the high-precision date-time string.

However, when serializing to JSON, the value will have the high-precision part lost due to the inability of JSON to handle high-precision date-time.

> c = Ceramic.from_xml(xml)
> #<Ceramic:0x0000000104ac7240 @kiln_firing_time=#<HighPrecisionDateTime:0x0000000104ac7240 @value=2012-04-07 01:51:37.112000000 +0200>>
> c.to_json
> # {"kilnFiringTime":"2012-04-07T01:51:37+02:00"}

Attribute as a collection

Define attributes as collections (arrays or hashes) to store multiple values using the collection option.

collection can be set to:

true

The attribute contains an unbounded collection of objects of the declared class.

{min}..{max}

The attribute contains a collection of objects of the declared class with a count within the specified range. If the number of objects is out of this numbered range, CollectionCountOutOfRangeError will be raised.

When set to 0..1, it means that the attribute is optional, it could be empty or contain one object of the declared class.

When set to 1.. (equivalent to 1..Infinity), it means that the attribute must contain at least one object of the declared class and can contain any number of objects.

When set to 5..10` means that there is a minimum of 5 and a maximum of 10 objects of the declared class. If the count of values for the attribute is less then 5 or greater then 10, the CollectionCountOutOfRangeError will be raised.

Syntax:

attribute :name_of_attribute, Type, collection: true
attribute :name_of_attribute, Type, collection: {min}..{max}
attribute :name_of_attribute, Type, collection: {min}..
Example 6. Using the collection option to define a collection attribute
class Studio < Lutaml::Model::Serializable
  attribute :location, :string
  attribute :potters, :string, collection: true
  attribute :address, :string, collection: 1..2
  attribute :hobbies, :string, collection: 0..
end
> Studio.new
> # address count is `0`, must be between 1 and 2  (Lutaml::Model::CollectionCountOutOfRangeError)
> Studio.new({ address: ["address 1", "address 2", "address 3"] })
> # address count is `3`, must be between 1 and 2  (Lutaml::Model::CollectionCountOutOfRangeError)
> Studio.new({ address: ["address 1"] }).potters
> # []
> Studio.new({ address: ["address 1"] }).address
> # ["address 1"]
> Studio.new(address: ["address 1"], potters: ['John Doe', 'Jane Doe']).potters
> # ['John Doe', 'Jane Doe']

Attribute value validation

General

There are several mechanisms to validate attribute values in Lutaml::Model.

Values of an enumeration

An attribute can be defined as an enumeration by using the values directive.

The values directive is used to define acceptable values in an attribute. If any other value is given, a Lutaml::Model::InvalidValueError will be raised.

Syntax:

attribute :name_of_attribute, Type, values: [value1, value2, ...]

The values set inside the values: option can be of any type, but they must match the type of the attribute. The values are compared using the == operator, so the type must implement the == method.

Example 7. Using the values directive to define acceptable values for an attribute (basic types)
class GlazeTechnique < Lutaml::Model::Serializable
  attribute :name, :string, values: ["Celadon", "Raku", "Majolica"]
end
> GlazeTechnique.new(name: "Celadon").name
> # "Celadon"
> GlazeTechnique.new(name: "Raku").name
> # "Raku"
> GlazeTechnique.new(name: "Majolica").name
> # "Majolica"
> GlazeTechnique.new(name: "Earthenware").name
> # Lutaml::Model::InvalidValueError: Invalid value for attribute 'name'

The values can be Serialize objects, which are compared using the == and the hash methods through the Lutaml::Model::ComparableModel module.

Example 8. Using the values directive to define acceptable values for an attribute (Serializable objects)
class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :firing_temperature, :integer
end

class CeramicCollection < Lutaml::Model::Serializable
  attribute :featured_piece,
            Ceramic,
            values: [
              Ceramic.new(type: "Porcelain", firing_temperature: 1300),
              Ceramic.new(type: "Stoneware", firing_temperature: 1200),
              Ceramic.new(type: "Earthenware", firing_temperature: 1000),
            ]
end
> CeramicCollection.new(featured_piece: Ceramic.new(type: "Porcelain", firing_temperature: 1300)).featured_piece
> # Ceramic:0x0000000104ac7240 @type="Porcelain", @firing_temperature=1300
> CeramicCollection.new(featured_piece: Ceramic.new(type: "Bone China", firing_temperature: 1300)).featured_piece
> # Lutaml::Model::InvalidValueError: Invalid value for attribute 'featured_piece'

Serialize provides a validate method that checks if all its attributes have valid values. This is necessary for the case when a value is valid at the component level, but not accepted at the aggregation level.

If a change has been made at the component level (a nested attribute has changed), the aggregation level needs to call the validate method to verify acceptance of the newly updated component.

Example 9. Using the validate method to check if all attributes have valid values
> collection = CeramicCollection.new(featured_piece: Ceramic.new(type: "Porcelain", firing_temperature: 1300))
> collection.featured_piece.firing_temperature = 1400
> # No error raised in changed nested attribute
> collection.validate
> # Lutaml::Model::InvalidValueError: Invalid value for attribute 'featured_piece'

String values restricted to patterns

An attribute that accepts a string value accepts value validation using regular expressions.

Syntax:

attribute :name_of_attribute, :string, pattern: /regex/
Example 10. Using the pattern option to restrict the value of an attribute

In this example, the color attribute takes hex color values such as #ccddee.

A regular expression can be used to validate values assigned to the attribute. In this case, it is /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.

class Glaze < Lutaml::Model::Serializable
  attribute :color, :string, pattern: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/
end
> Glaze.new(color: '#ff0000').color
> # "#ff0000"
> Glaze.new(color: '#ff000').color
> # Lutaml::Model::InvalidValueError: Invalid value for attribute 'color'

Attribute value default and rendering defaults

Specify default values for attributes using the default option. The default option can be set to a value or a lambda that returns a value.

Syntax:

attribute :name_of_attribute, Type, default: -> { value }
Example 11. Using the default option to set a default value for an attribute
class Glaze < Lutaml::Model::Serializable
  attribute :color, :string, default: -> { 'Clear' }
  attribute :temperature, :integer, default: -> { 1050 }
end
> Glaze.new.color
> # "Clear"
> Glaze.new.temperature
> # 1050

The "default behavior" (pun intended) is to not render a default value if the current value is the same as the default value.

In certain cases, it is necessary to render the default value even if the current value is the same as the default value. This can be achieved by setting the render_default option to true.

Syntax:

attribute :name_of_attribute, Type, default: -> { value }, render_default: true
Example 12. Using the render_default option to force encoding the default value
class Glaze < Lutaml::Model::Serializable
  attribute :color, :string, default: -> { 'Clear' }
  attribute :opacity, :string, default: -> { 'Opaque' }
  attribute :temperature, :integer, default: -> { 1050 }
  attribute :firing_time, :integer, default: -> { 60 }

  xml do
    root "glaze"
    map_element 'color', to: :color
    map_element 'opacity', to: :opacity, render_default: true
    map_attribute 'temperature', to: :temperature
    map_attribute 'firingTime', to: :firing_time, render_default: true
  end

  json do
    map 'color', to: :color
    map 'opacity', to: :opacity, render_default: true
    map 'temperature', to: :temperature
    map 'firingTime', to: :firing_time, render_default: true
  end
end
Example 13. Attributes with render_default: true are rendered when the value is identical to the default
> glaze_new = Glaze.new
> puts glaze_new.to_xml
# <glaze firingTime="60">
#   <opacity>Opaque</opacity>
# </glaze>
> puts glaze_new.to_json
# {"firingTime":60,"opacity":"Opaque"}
Example 14. Attributes with render_default: true with non-default values are rendered
> glaze = Glaze.new(color: 'Celadon', opacity: 'Semitransparent', temperature: 1300, firing_time: 90)
> puts glaze.to_xml
# <glaze color="Celadon" temperature="1300" firingTime="90">
#   <opacity>Semitransparent</opacity>
# </glaze>
> puts glaze.to_json
# {"color":"Celadon","temperature":1300,"firingTime":90,"opacity":"Semitransparent"}

Attribute as raw string

An attribute can be set to read the value as raw string for XML, by using the raw: true option.

Syntax:

attribute :name_of_attribute, :string, raw: true
Example 15. Using the raw option to read raw value for an XML attribute
class Person < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :description, :string, raw: true
end

For the following XML snippet:

<Person>
  <name>John Doe</name>
  <description>
    A <b>fictional person</b> commonly used as a <i>placeholder name</i>.
  </description>
</Person>
> Person.from_xml(xml)
> # <Person:0x0000000107a3ca70
    @description="\n    A <b>fictional person</b> commonly used as a <i>placeholder name</i>.\n  ",
    @element_order=["text", "name", "text", "description", "text"],
    @name="John Doe",
    @ordered=nil>

Serialization model mappings

General

Lutaml::Model allows you to translate a data model into serialization models of various serialization formats including XML, JSON, YAML, and TOML.

Depending on the serialization format, different methods are supported for defining serialization and deserialization mappings.

Serialization model mappings are defined under the xml, json, yaml, and toml blocks.

Using the xml, json, yaml, and toml blocks to define serialization mappings
class Example < Lutaml::Model::Serializable
  xml do
    # ...
  end

  json do
    # ...
  end

  yaml do
    # ...
  end

  toml do
    # ...
  end
end

XML

Setting root element name

The root method sets the root element tag name of the XML document.

If root is not given, then the snake-cased class name will be used as the root.

Sets the tag name for <example> in XML <example>…​</example>.

Syntax:

xml do
  root 'xml_element_name'
end
Example 16. Setting the root element name to example
class Example < Lutaml::Model::Serializable
  xml do
    root 'example'
  end
end
> Example.new.to_xml
> #<example></example>

Mapping all content (XML only)

Warning
This feature is only applicable to XML (for now).

The map_all tag in XML mapping captures and maps all content within an XML element into a single attribute in the target Ruby object.

The use case for map_all is to tell Lutaml::Model to not parse the content of the XML element at all, and instead handle it as an XML string.

This is useful in the case where the content of an XML element is not to be handled by a Lutaml::Model::Serializable object.

This feature is commonly used with custom methods or a custom model object to handle the content.

This includes:

  • nested tags

  • attributes

  • text nodes

The map_all tag is exclusive and cannot be combined with other mappings (map_element, map_content) except for map_attribute for the same element, ensuring it captures the entire inner XML content.

Note
An error is raised if map_all is defined alongside any other mapping in the same XML mapping context.

Syntax:

xml do
  map_all to: :name_of_attribute
end
Example 17. Mapping all the content using map_all
class ExampleMapping < Lutaml::Model::Serializable
  attribute :description, :string

  xml do
    map_all to: :description
  end
end
<ExampleMapping>Content with <b>tags</b> and <i>formatting</i>.</ExampleMapping>
> parsed = ExampleMapping.from_xml(xml)
> puts parsed.all_content
# "Content with <b>tags</b> and <i>formatting</i>."

Mapping elements

The map_element method maps an XML element to a data model attribute.

To handle the <name> tag in <example><name>John Doe</name></example>. The value will be set to John Doe.

Syntax:

xml do
  map_element 'xml_element_name', to: :name_of_attribute
end
Example 18. Mapping the name tag to the name attribute
class Example < Lutaml::Model::Serializable
  attribute :name, :string

  xml do
    root 'example'
    map_element 'name', to: :name
  end
end
<example><name>John Doe</name></example>
> Example.from_xml(xml)
> #<Example:0x0000000104ac7240 @name="John Doe">
> Example.new(name: "John Doe").to_xml
> #<example><name>John Doe</name></example>

If an element is mapped to a model object with the XML root tag name set, the mapped tag name will be used as the root name, overriding the root name.

Example 19. The mapped tag name is used as the root name
class RecordDate < Lutaml::Model::Serializable
  attribute :content, :string

  xml do
    root "recordDate"
    map_content to: :content
  end
end

class OriginInfo < Lutaml::Model::Serializable
  attribute :date_issued, RecordDate, collection: true

  xml do
    root "originInfo"
    map_element "dateIssued", to: :date_issued
  end
end
> RecordDate.new(date: "2021-01-01").to_xml
> #<recordDate>2021-01-01</recordDate>
> OriginInfo.new(date_issued: [RecordDate.new(date: "2021-01-01")]).to_xml
> #<originInfo><dateIssued>2021-01-01</dateIssued></originInfo>

Mapping attributes

The map_attribute method maps an XML attribute to a data model attribute.

Syntax:

xml do
  map_attribute 'xml_attribute_name', to: :name_of_attribute
end
Example 20. Using map_attribute to map the value attribute

The following class will parse the XML snippet below:

class Example < Lutaml::Model::Serializable
  attribute :value, :integer

  xml do
    root 'example'
    map_attribute 'value', to: :value
  end
end
<example value="12"><name>John Doe</name></example>
> Example.from_xml(xml)
> #<Example:0x0000000104ac7240 @value=12>
> Example.new(value: 12).to_xml
> #<example value="12"></example>

The map_attribute method does not inherit the root element’s namespace. To specify a namespace for an attribute, please explicitly declare the namespace and prefix in the map_attribute method.

The following class will parse the XML snippet below:

class Attribute < Lutaml::Model::Serializable
  attribute :value, :integer

  xml do
    root 'example'
    map_attribute 'value', to: :value, namespace: "http://www.tech.co/XMI", prefix: "xl"
  end
end
<example xl:value="20" xmlns:xl="http://www.tech.co/XMI"></example>
> Attribute.from_xml(xml)
> #<Attribute:0x0000000109436db8 @value=20>
> Attribute.new(value: 20).to_xml
> #<example xmlns:xl=\"http://www.tech.co/XMI\" xl:value=\"20\"/>

Mapping content

Content represents the text inside an XML element, inclusive of whitespace.

The map_content method maps an XML element’s content to a data model attribute.

Syntax:

xml do
  map_content to: :name_of_attribute
end
Example 21. Using map_content to map content of the description tag

The following class will parse the XML snippet below:

class Example < Lutaml::Model::Serializable
  attribute :description, :string

  xml do
    root 'example'
    map_content to: :description
  end
end
<example>John Doe is my moniker.</example>
> Example.from_xml(xml)
> #<Example:0x0000000104ac7240 @description="John Doe is my moniker.">
> Example.new(description: "John Doe is my moniker.").to_xml
> #<example>John Doe is my moniker.</example>

CDATA nodes

CDATA is an XML feature that allows the inclusion of text that may contain characters that are unescaped in XML.

While CDATA is not preferred in XML, it is sometimes necessary to handle CDATA nodes for both input and output.

Note
The W3C XML Recommendation explicitly encourages escaping characters over usage of CDATA.

Lutaml::Model supports the handling of CDATA nodes in XML in the following behavior:

  1. When an attribute contains a CDATA node with no text:

    • On reading: The node (CDATA or text) is read as its value.

    • On writing: The value is written as its native type.

  2. When an XML mapping sets cdata: true on map_element or map_content:

    • On reading: The node (CDATA or text) is read as its value.

    • On writing: The value is written as a CDATA node.

  3. When an XML mapping sets cdata: false on map_element or map_content:

    • On reading: The node (CDATA or text) is read as its value.

    • On writing: The value is written as a text node (string).

Syntax:

xml do
  map_content to: :name_of_attribute, cdata: (true | false)
  map_element :name, to: :name, cdata: (true | false)
end
Example 22. Using cdata to map CDATA content

The following class will parse the XML snippet below:

class Example < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :description, :string
  attribute :title, :string
  attribute :note, :string

  xml do
    root 'example'
    map_element :name, to: :name, cdata: true
    map_content to: :description, cdata: true
    map_element :title, to: :title, cdata: false
    map_element :note, to: :note, cdata: false
  end
end
<example><name><![CDATA[John]]></name><![CDATA[here is the description]]><title><![CDATA[Lutaml]]></title><note>Careful</note></example>
> Example.from_xml(xml)
> #<Example:0x0000000104ac7240 @name="John" @description="here is the description" @title="Lutaml" @note="Careful">
> Example.new(name: "John", description: "here is the description", title: "Lutaml", note: "Careful").to_xml
> #<example><name><![CDATA[John]]></name><![CDATA[here is the description]]><title>Lutaml</title><note>Careful</note></example>

Example for mapping

The following class will parse the XML snippet below:

class Example < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :description, :string
  attribute :value, :integer

  xml do
    root 'example'
    map_element 'name', to: :name
    map_attribute 'value', to: :value
    map_content to: :description
  end
end
<example value="12"><name>John Doe</name> is my moniker.</example>
> Example.from_xml(xml)
> #<Example:0x0000000104ac7240 @name="John Doe", @description=" is my moniker.", @value=12>
> Example.new(name: "John Doe", description: " is my moniker.", value: 12).to_xml
> #<example value="12"><name>John Doe</name> is my moniker.</example>

Encoding Options in XmlAdapter

XmlAdapter supports the encoding in the following ways:

  1. When encoding is not passed in to_xml:

    • Default encoding is UTF-8.

  2. When encoding is explicitly passed nil:

    • Encoding will be nil, show the HexCode(Nokogiri) or ASCII-8bit(Ox).

  3. When encoding is passed with some option:

    • Encoding option will be selected as passed.

Syntax:

Example.new(description: " ∑ is my ∏ moniker µ.").to_xml
Example.new(description: " ∑ is my ∏ moniker µ.").to_xml(encoding: nil)
Example.new(description: " ∑ is my ∏ moniker µ.").to_xml(encoding: "ASCII")

The following class will parse the XML snippet below:

class Example < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :description, :string
  attribute :value, :integer

  xml do
    root 'example'
    map_element 'name', to: :name
    map_content to: :description
  end
end
<example><name>John &#x0026; Doe</name> &#x2211; is my &#x220F; moniker &#xB5;.</example>
> Example.from_xml(xml)
> #<Example:0x0000000104ac7240 @name="John & Doe", @description=" ∑ is my ∏ moniker µ.">
> Example.new(name: "John & Doe", description: " ∑ is my ∏ moniker µ.").to_xml
> #<example><name>John &amp; Doe</name> ∑ is my ∏ moniker µ.</example>

> Example.new(name: "John & Doe", description: " ∑ is my ∏ moniker µ.").to_xml(encoding: nil)
> #<example><name>John &amp; Doe</name> &#x2211; is my &#x220F; moniker &#xB5;.</example>

> Example.new(name: "John & Doe", description: " ∑ is my ∏ moniker µ.").to_xml(encoding: "ASCII")
> #<example><name>John &amp; Doe</name> &#8721; is my &#8719; moniker &#181;.</example>

Namespaces

Namespace at root

The namespace method in the xml block sets the namespace for the root element.

Syntax:

Setting default namespace at the root element
xml do
  namespace 'http://example.com/namespace'
end
Setting a prefixed namespace at the root element
xml do
  namespace 'http://example.com/namespace', 'prefix'
end
Example 23. Using the namespace method to set the namespace for the root element
class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, :string

  xml do
    root 'Ceramic'
    namespace 'http://example.com/ceramic'
    map_element 'Type', to: :type
    map_element 'Glaze', to: :glaze
  end
end
<Ceramic xmlns='http://example.com/ceramic'><Type>Porcelain</Type><Glaze>Clear</Glaze></Ceramic>
> Ceramic.from_xml(xml_file)
> #<Ceramic:0x0000000104ac7240 @type="Porcelain", @glaze="Clear">
> Ceramic.new(type: "Porcelain", glaze: "Clear").to_xml
> #<Ceramic xmlns="http://example.com/ceramic"><Type>Porcelain</Type><Glaze>Clear</Glaze></Ceramic>
Example 24. Using the namespace method to set a prefixed namespace for the root element
class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, :string

  xml do
    root 'Ceramic'
    namespace 'http://example.com/ceramic', 'cer'
    map_element 'Type', to: :type
    map_element 'Glaze', to: :glaze
  end
end
<cer:Ceramic xmlns='http://example.com/ceramic'><cer:Type>Porcelain</cer:Type><cer:Glaze>Clear</cer:Glaze></cer:Ceramic>
> Ceramic.from_xml(xml_file)
> #<Ceramic:0x0000000104ac7240 @type="Porcelain", @glaze="Clear">
> Ceramic.new(type: "Porcelain", glaze: "Clear").to_xml
> #<cer:Ceramic xmlns="http://example.com/ceramic"><cer:Type>Porcelain</cer:Type><cer:Glaze>Clear</cer:Glaze></cer:Ceramic>
Namespace on attribute

If the namespace is defined on a model attribute that already has a namespace, the mapped namespace will be given priority over the one defined in the class.

Syntax:

xml do
  map_element 'xml_element_name', to: :name_of_attribute,
    namespace: 'http://example.com/namespace',
    prefix: 'prefix'
end
namespace

The XML namespace used by this element

prefix

The XML namespace prefix used by this element (optional)

Example 25. Using the namespace option to set the namespace for an element

In this example, glz will be used for Glaze if it is added inside the Ceramic class, and glaze will be used otherwise.

class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, Glaze

  xml do
    root 'Ceramic'
    namespace 'http://example.com/ceramic'

    map_element 'Type', to: :type
    map_element 'Glaze', to: :glaze, namespace: 'http://example.com/glaze', prefix: "glz"
  end
end

class Glaze < Lutaml::Model::Serializable
  attribute :color, :string
  attribute :temperature, :integer

  xml do
    root 'Glaze'
    namespace 'http://example.com/old_glaze', 'glaze'

    map_element 'color', to: :color
    map_element 'temperature', to: :temperature
  end
end
<Ceramic xmlns='http://example.com/ceramic'>
  <Type>Porcelain</Type>
  <glz:Glaze xmlns='http://example.com/glaze'>
    <color>Clear</color>
    <temperature>1050</temperature>
  </glz:Glaze>
</Ceramic>
> # Using the original Glaze class namespace
> Glaze.new(color: "Clear", temperature: 1050).to_xml
> #<glaze:Glaze xmlns="http://example.com/old_glaze"><color>Clear</color><temperature>1050</temperature></glaze:Glaze>

> # Using the Ceramic class namespace for Glaze
> Ceramic.from_xml(xml_file)
> #<Ceramic:0x0000000104ac7240 @type="Porcelain", @glaze=#<Glaze:0x0000000104ac7240 @color="Clear", @temperature=1050>>
> Ceramic.new(type: "Porcelain", glaze: Glaze.new(color: "Clear", temperature: 1050)).to_xml
> #<Ceramic xmlns="http://example.com/ceramic"><Type>Porcelain</Type><glz:Glaze xmlns="http://example.com/glaze"><color>Clear</color><temperature>1050</temperature></glz:Glaze></Ceramic>
Namespace with inherit option

The inherit option is used at the element level to inherit the namespace from the root element.

Syntax:

xml do
  map_element 'xml_element_name', to: :name_of_attribute, namespace: :inherit
end
Example 26. Using the inherit option to inherit the namespace from the root element

In this example, the Type element will inherit the namespace from the root.

class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, :string
  attribute :color, :string

  xml do
    root 'Ceramic'
    namespace 'http://example.com/ceramic', 'cera'
    map_element 'Type', to: :type, namespace: :inherit
    map_element 'Glaze', to: :glaze
    map_attribute 'color', to: :color, namespace: 'http://example.com/color', prefix: 'clr'
  end
end
<cera:Ceramic
  xmlns:cera='http://example.com/ceramic'
  xmlns:clr='http://example.com/color'
  clr:color="navy-blue">
  <cera:Type>Porcelain</cera:Type>
  <Glaze>Clear</Glaze>
</cera:Ceramic>
> Ceramic.from_xml(xml_file)
> #<Ceramic:0x0000000104ac7240 @type="Porcelain", @glaze="Clear", @color="navy-blue">
> Ceramic.new(type: "Porcelain", glaze: "Clear", color: "navy-blue").to_xml
> #<cera:Ceramic xmlns:cera="http://example.com/ceramic"
  # xmlns:clr='http://example.com/color'
  # clr:color="navy-blue">
  #  <cera:Type>Porcelain</cera:Type>
  #  <Glaze>Clear</Glaze>
  # </cera:Ceramic>

Mixed content

In XML there can be tags that contain content mixed with other tags and where whitespace is significant, such as to represent rich text.

<description><p>My name is <bold>John Doe</bold>, and I'm <i>28</i> years old</p></description>

To map this to Lutaml::Model we can use the mixed option in either way:

  • when defining the model;

  • when referencing the model.

Note
This feature is not supported by Shale.

To specify mixed content, the mixed: true option needs to be set at the xml block’s root method.

Syntax:

xml do
  root 'xml_element_name', mixed: true
end
Example 27. Applying mixed to treat root as mixed content
class Paragraph < Lutaml::Model::Serializable
  attribute :bold, :string, collection: true # allows multiple bold tags
  attribute :italic, :string

  xml do
    root 'p', mixed: true

    map_element 'bold', to: :bold
    map_element 'i', to: :italic
  end
end
> Paragraph.from_xml("<p>My name is <bold>John Doe</bold>, and I'm <i>28</i> years old</p>")
> #<Paragraph:0x0000000104ac7240 @bold="John Doe", @italic="28">
> Paragraph.new(bold: "John Doe", italic: "28").to_xml
> #<p>My name is <bold>John Doe</bold>, and I'm <i>28</i> years old</p>

Automatic support of xsi:schemaLocation

The W3C "XMLSchema-instance" namespace describes a number of attributes that can be used to control the behavior of XML processors. One of these attributes is xsi:schemaLocation.

The xsi:schemaLocation attribute locates schemas for elements and attributes that are in a specified namespace. Its value consists of pairs of a namespace URI followed by a relative or absolute URL where the schema for that namespace can be found.

Usage of xsi:schemaLocation in an XML element depends on the declaration of the XML namespace of xsi, i.e. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance". Without this namespace LutaML will not be able to serialize the xsi:schemaLocation attribute.

Note
It is most commonly attached to the root element but can appear further down the tree.

The following snippet shows how xsi:schemaLocation is used in an XML document:

<cera:Ceramic
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:cera="http://example.com/ceramic"
  xmlns:clr='http://example.com/color'
  xsi:schemaLocation=
    "http://example.com/ceramic http://example.com/ceramic.xsd
     http://example.com/color http://example.com/color.xsd"
  clr:color="navy-blue">
  <cera:Type>Porcelain</cera:Type>
  <Glaze>Clear</Glaze>
</cera:Ceramic>

LutaML::Model supports the xsi:schemaLocation attribute in all XML serializations by default, through the schema_location attribute on the model instance object.

Example 28. Retrieving and setting the xsi:schemaLocation attribute in XML serialization

In this example, the xsi:schemaLocation attribute will be automatically supplied without the explicit need to define in the model, and allows for round-trip serialization.

class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, :string
  attribute :color, :string

  xml do
    root 'Ceramic'
    namespace 'http://example.com/ceramic', 'cera'
    map_element 'Type', to: :type, namespace: :inherit
    map_element 'Glaze', to: :glaze
    map_attribute 'color', to: :color, namespace: 'http://example.com/color', prefix: 'clr'
  end
end

xml_content = <<~HERE
<cera:Ceramic
  xmlns:cera="http://example.com/ceramic"
  xmlns:clr="http://example.com/color"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  clr:color="navy-blue"
  xsi:schemaLocation="
    http://example.com/ceramic http://example.com/ceramic.xsd
    http://example.com/color http://example.com/color.xsd
  ">
  <cera:Type>Porcelain</cera:Type>
  <Glaze>Clear</Glaze>
</cera:Ceramic>
HERE
> c = Ceramic.from_xml(xml_content)
=>
#<Ceramic:0x00000001222bdd60
...
> schema_loc = c.schema_location
#<Lutaml::Model::SchemaLocation:0x0000000122773760
...
> schema_loc
=>
#<Lutaml::Model::SchemaLocation:0x0000000122773760
 @namespace="http://www.w3.org/2001/XMLSchema-instance",
 @original_schema_location="http://example.com/ceramic http://example.com/ceramic.xsd http://example.com/color http://example.com/color.xsd",
 @prefix="xsi",
 @schema_location=
  [#<Lutaml::Model::Location:0x00000001222bd018 @location="http://example.com/ceramic.xsd", @namespace="http://example.com/ceramic">,
   #<Lutaml::Model::Location:0x00000001222bcfc8 @location="http://example.com/color.xsd", @namespace="http://example.com/color">]>
> new_c = Ceramic.new(type: "Porcelain", glaze: "Clear", color: "navy-blue", schema_location: schema_loc).to_xml
> puts new_c
# <cera:Ceramic
#   xmlns:cera="http://example.com/ceramic"
#   xmlns:clr="http://example.com/color"
#   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
#   clr:color="navy-blue"
#   xsi:schemaLocation="
#     http://example.com/ceramic http://example.com/ceramic.xsd
#     http://example.com/color http://example.com/color.xsd
#   ">
#   <cera:Type>Porcelain</cera:Type>
#   <cera:Glaze>Clear</cera:Glaze>
# </cera:Ceramic>
Note
For details on xsi:schemaLocation, please refer to the W3C XML standard.

Key value data models

General

Key-value data models like JSON, YAML, and TOML all share a similar structure where data is stored as key-value pairs.

Lutaml::Model works with these formats in a similar way.

Mapping

The map method is used to define key-value mappings.

Syntax:

json | yaml | toml | key_value do
  map 'key_value_model_attribute_name', to: :name_of_attribute
end

Unified mapping

The key_value method is a streamlined way to map all attributes for serialization into key-value formats including JSON, YAML, and TOML.

If there is no definite differentiation between the key value formats, the key_value method simplifies defining mappings and improves code readability.

Example 29. Using the map method to define the same mappings across all key-value formats

This example shows how to define a key-value data model with the key_value method which maps the same attributes across all key-value formats.

class CeramicModel < Lutaml::Model::Serializable
  attribute :color, :string
  attribute :glaze, :string
  attribute :description, :string

  key_value do
    map :color, to: color
    map :glz, to: :glaze
    map :desc, to: :description
  end

  # Equivalent to the JSON, YAML, and TOML mappings.
  #
  # json and yaml and toml do
  #   map :id, to: color
  #   map :name, to: :full_name
  #   map :status, to: :current_status
  # end
end
{
  "color": "Navy Blue",
  "glz": "Clear",
  "desc": "A ceramic with a navy blue color and clear glaze."
}
color: Navy Blue
glz: Clear
desc: A ceramic with a navy blue color and clear glaze.
> CeramicModel.from_json(json)
> #<CeramicModel:0x0000000104ac7240 @color="Navy Blue", @glaze="Clear", @description="A ceramic with a navy blue color and clear glaze.">
> CeramicModel.new(color: "Navy Blue", glaze: "Clear", description: "A ceramic with a navy blue color and clear glaze.").to_json
> #{"color"=>"Navy Blue", "glz"=>"Clear", "desc"=>"A ceramic with a navy blue color and clear glaze."}

Specific format mappings

Specific key value formats can be mapping independently of other formats, including:

  • json for the JSON format

  • yaml for the YAML format

  • toml for the TOML format

Example 30. Using the map method to define key-value mappings per format
class Example < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :value, :integer

  json do
    map 'name', to: :name
    map 'value', to: :value
  end

  yaml do
    map 'name', to: :name
    map 'value', to: :value
  end

  toml do
    map 'name', to: :name
    map 'value', to: :value
  end
end
{
  "name": "John Doe",
  "value": 28
}
> Example.from_json(json)
> #<Example:0x0000000104ac7240 @name="John Doe", @value=28>
> Example.new(name: "John Doe", value: 28).to_json
> #{"name"=>"John Doe", "value"=>28}

Nested attribute mappings

The map method can also be used to map nested key-value data models by referring to a Lutaml::Model class as an attribute class.

class Glaze < Lutaml::Model::Serializable
  attribute :color, :string
  attribute :temperature, :integer

  json do
    map 'color', to: :color
    map 'temperature', to: :temperature
  end
end

class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, Glaze

  json do
    map 'type', to: :type
    map 'glaze', to: :glaze
  end
end
{
  "type": "Porcelain",
  "glaze": {
    "color": "Clear",
    "temperature": 1050
  }
}
> Ceramic.from_json(json)
> #<Ceramic:0x0000000104ac7240 @type="Porcelain", @glaze=#<Glaze:0x0000000104ac7240 @color="Clear", @temperature=1050>>
> Ceramic.new(type: "Porcelain", glaze: Glaze.new(color: "Clear", temperature: 1050)).to_json
> #{"type"=>"Porcelain", "glaze"=>{"color"=>"Clear", "temperature"=>1050}}

Separate serialization model

The Serialize module can be used to define only serialization mappings for a separately defined model (a Ruby class).

Syntax:

class Foo < Lutaml::Model::Serializable
  model {DataModelClass}

  # ...
end
Example 31. Using the model method to define serialization mappings for a separate model
class Ceramic
  attr_accessor :type, :glaze

  def name
    "#{type} with #{glaze}"
  end
end

class CeramicSerialization < Lutaml::Model::Serializable
  model Ceramic

  xml do
    map_element 'type', to: :type
    map_element 'glaze', to: :glaze
  end
end
> Ceramic.new(type: "Porcelain", glaze: "Clear").name
> # "Porcelain with Clear"
> CeramicSerialization.from_xml(xml)
> #<Ceramic:0x0000000104ac7240 @type="Porcelain", @glaze="Clear">
> Ceramic.new(type: "Porcelain", glaze: "Clear").to_xml
> #<Ceramic><type>Porcelain</type><glaze>Clear</glaze></Ceramic>

Rendering empty attributes and collections

By default, empty attributes and collections are not rendered in the output.

To render empty attributes and collections, use the render_nil option.

Syntax:

xml do
  map_element 'key_value_model_attribute_name', to: :name_of_attribute, render_nil: true
end
json | yaml | toml do
  map 'key_value_model_attribute_name', to: :name_of_attribute, render_nil: true
end
Example 32. Using the render_nil option to render empty attributes
class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, :string

  xml do
    map_element 'type', to: :type, render_nil: true
    map_element 'glaze', to: :glaze
  end

  json do
    map 'type', to: :type, render_nil: true
    map 'glaze', to: :glaze
  end
end
> Ceramic.new.to_json
> # { 'type': null }
> Ceramic.new(type: "Porcelain", glaze: "Clear").to_json
> # { 'type': 'Porcelain', 'glaze': 'Clear' }
> Ceramic.new.to_xml
> # <Ceramic><type></type></Ceramic>
> Ceramic.new(type: "Porcelain", glaze: "Clear").to_xml
> # <Ceramic><type>Porcelain</type><glaze>Clear</glaze></Ceramic>
Example 33. Using the render_nil option to render empty attribute collections
class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glazes, :string, collection: true

  xml do
    map_element 'type', to: :type, render_nil: true
    map_element 'glazes', to: :glazes, render_nil: true
  end

  json do
    map 'type', to: :type, render_nil: true
    map 'glazes', to: :glazes, render_nil: true
  end
end
> Ceramic.new.to_json
> # { 'type': null, 'glazes': [] }
> Ceramic.new(type: "Porcelain", glazes: ["Clear"]).to_json
> # { 'type': 'Porcelain', 'glazes': ['Clear'] }
> Ceramic.new.to_xml
> # <Ceramic><type></type><glazes></glazes></Ceramic>
> Ceramic.new(type: "Porcelain", glazes: ["Clear"]).to_xml
> # <Ceramic><type>Porcelain</type><glazes>Clear</glazes></Ceramic>

Advanced attribute mapping

Attribute mapping delegation

Delegate attribute mappings to nested objects using the delegate option.

Syntax:

xml | json | yaml | toml do
  map 'key_value_model_attribute_name', to: :name_of_attribute, delegate: :model_to_delegate_to
end
Example 34. Using the delegate option to map attributes to nested objects

The following class will parse the JSON snippet below:

class Glaze < Lutaml::Model::Serializable
  attribute :color, :string
  attribute :temperature, :integer

  json do
    map 'color', to: :color
    map 'temperature', to: :temperature
  end
end

class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, Glaze

  json do
    map 'type', to: :type
    map 'color', to: :color, delegate: :glaze
  end
end
{
  "type": "Porcelain",
  "color": "Clear"
}
> Ceramic.from_json(json)
> #<Ceramic:0x0000000104ac7240 @type="Porcelain", @glaze=#<Glaze:0x0000000104ac7240 @color="Clear", @temperature=nil>>
> Ceramic.new(type: "Porcelain", glaze: Glaze.new(color: "Clear")).to_json
> #{"type"=>"Porcelain", "color"=>"Clear"}
Note
The corresponding keyword used by Shale is receiver: instead of delegate:.

Attribute serialization with custom methods

General

Define custom methods for specific attribute mappings using the with: key for each serialization mapping block for from and to.

XML serialization with custom methods

Syntax:

XML serialization with custom methods
xml do
  map_element 'element_name', to: :name_of_element, with: {
    to: :method_name_to_serialize,
    from: :method_name_to_deserialize
  }
  map_attribute 'attribute_name', to: :name_of_attribute, with: {
    to: :method_name_to_serialize,
    from: :method_name_to_deserialize
  }
  map_content, to: :name_of_content, with: {
    to: :method_name_to_serialize,
    from: :method_name_to_deserialize
  }
end
Example 35. Using the with: key to define custom serialization methods for XML

The following class will parse the XML snippet below:

class CustomCeramic < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :size, :integer
  attribute :description, :string

  xml do
    map_element "Name", to: :name, with: { to: :name_to_xml, from: :name_from_xml }
    map_attribute "Size", to: :size, with: { to: :size_to_xml, from: :size_from_xml }
    map_content with: { to: :description_to_xml, from: :description_from_xml }
  end

  def name_to_xml(model, parent, doc)
    el = doc.create_element("Name")
    doc.add_text(el, "XML Masterpiece: #{model.name}")
    doc.add_element(parent, el)
  end

  def name_from_xml(model, value)
    model.name = value.sub(/^XML Masterpiece: /, "")
  end

  def size_to_xml(model, parent, doc)
    doc.add_attribute(parent, "Size", model.size + 3)
  end

  def size_from_xml(model, value)
    model.size = value.to_i - 3
  end

  def description_to_xml(model, parent, doc)
    doc.add_text(parent, "XML Description: #{model.description}")
  end

  def description_from_xml(model, value)
    model.description = value.join.strip.sub(/^XML Description: /, "")
  end
end
<CustomCeramic Size="15">
  <Name>XML Masterpiece: Vase</Name>
  XML Description: A beautiful ceramic vase
</CustomCeramic>
> CustomCeramic.from_xml(xml)
> #<CustomCeramic:0x0000000108d0e1f8
   @element_order=["text", "Name", "text", "Size", "text"],
   @name="Masterpiece: Vase",
   @ordered=nil,
   @size=12,
   @description="A beautiful ceramic vase">
> puts CustomCeramic.new(name: "Vase", size: 12, description: "A beautiful vase").to_xml
# <CustomCeramic Size="15">
#   <Name>XML Masterpiece: Vase</Name>
#   XML Description: A beautiful vase
# </CustomCeramic>
Key-value data model serialization with custom methods
Key-value data model serialization with custom methods
json | yaml | toml do
  map 'attribute_name', to: :name_of_attribute, with: {
    to: :method_name_to_serialize,
    from: :method_name_to_deserialize
  }
end
Example 36. Using the with: key to define custom serialization methods

The following class will parse the JSON snippet below:

class CustomCeramic < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :size, :integer

  json do
    map 'name', to: :name, with: { to: :name_to_json, from: :name_from_json }
    map 'size', to: :size
  end

  def name_to_json(model, doc)
    doc["name"] = "Masterpiece: #{model.name}"
  end

  def name_from_json(model, value)
    model.name = value.sub(/^Masterpiece: /, '')
  end
end
{
  "name": "Masterpiece: Vase",
  "size": 12
}
> CustomCeramic.from_json(json)
> #<CustomCeramic:0x0000000104ac7240 @name="Vase", @size=12>
> CustomCeramic.new(name: "Vase", size: 12).to_json
> #{"name"=>"Masterpiece: Vase", "size"=>12}

Attribute extraction (for key-value data models only)

Note
This feature is for key-value data model serialization only.

The child_mappings option is used to extract results from a key-value data model (JSON, YAML, TOML) into a Lutaml::Model collection.

The values are extracted from the key-value data model using the list of keys provided.

Syntax:

json | yaml | toml do
  map 'key_value_model_attribute_name', to: :name_of_attribute,
    child_mappings: {
      key_attribute_name_1: (1)
        {path_to_value_1}, (2)
      key_attribute_name_2:
        {path_to_value_2},
      # ...
    }
end
  1. The key_attribute_name_1 is the attribute name in the model. The value of this attribute will be assigned the key of the hash in the key-value data model.

  2. The path_to_value_1 is an array of keys that represent the path to the value in the key-value data model. The keys are used to extract the value from the key-value data model and assign it to the attribute in the model.

The path_to_value is in a nested array format with each value a symbol, where each symbol represents a key to traverse down. The last key in the path is the value to be extracted.

Example 37. Determining the path to value in a key-value data model

The following JSON contains 2 keys in schema named engine and gearbox.

{
  "components": {
    "engine": {
      "manufacturer": "Ford",
      "model": "V8"
    },
    "gearbox": {
      "manufacturer": "Toyota",
      "model": "4-speed"
    }
  }
}

The path to value for the engine schema is [:components, :engine] and for the gearbox schema is [:components, :gearbox].

In path_to_value, the :key and :value are reserved instructions used to assign the key or value of the serialization data respectively as the value to the attribute.

In the following JSON content, the path_to_value for the object keys named engine and gearbox will utilize the :key keyword to assign the key of the object as the value of a designated attribute.

{
  "components": {
    "engine": { /*...*/ },
    "gearbox": { /*...*/ }
  }
}

If a specified value path is not found, the corresponding attribute in the model will be assigned a nil value.

Example 38. Attribute values set to nil when the path_to_value is not found

In the following JSON content, the path_to_value of [:extras, :sunroof] and [:extras, :drinks_cooler] at the object "gearbox" would be set to nil.

{
  "components": {
    "engine": {
      "manufacturer": "Ford",
      "extras": {
        "sunroof": true,
        "drinks_cooler": true
      }
    },
    "gearbox": {
      "manufacturer": "Toyota"
    }
  }
}
Example 39. Using the child_mappings option to extract values from a key-value data model

The following JSON contains 2 keys in schema named foo and bar.

{
  "schemas": {
    "foo": { (1)
      "path": { (2)
        "link": "link one",
        "name": "one"
      }
    },
    "bar": { (1)
      "path": { (2)
        "link": "link two",
        "name": "two"
      }
    }
  }
}
  1. The keys foo and bar are to be mapped to the id attribute.

  2. The nested path.link and path.name keys are used as the link and name attributes, respectively.

A model can be defined for this JSON as follows:

class Schema < Lutaml::Model::Serializable
  attribute :id, :string
  attribute :link, :string
  attribute :name, :string
end

class ChildMappingClass < Lutaml::Model::Serializable
  attribute :schemas, Schema, collection: true

  json do
    map "schemas", to: :schemas,
                   child_mappings: {
                     id: :key,
                     link: %i[path link],
                     name: %i[path name],
                   }
  end
end

The output becomes:

> ChildMappingClass.from_json(json)
> #<ChildMappingClass:0x0000000104ac7240
 @schemas=
  [#<Schema:0x0000000104ac6e30 @id="foo", @link="link one", @name="one">,
   #<Schema:0x0000000104ac58f0 @id="bar", @link="link two", @name="two">]>
> ChildMappingClass.new(schemas: [Schema.new(id: "foo", link: "link one", name: "one"), Schema.new(id: "bar", link: "link two", name: "two")]).to_json
> #{"schemas"=>{"foo"=>{"path"=>{"link"=>"link one", "name"=>"one"}}, {"bar"=>{"path"=>{"link"=>"link two", "name"=>"two"}}}}

In this example:

  • The key of each schema (foo and bar) is mapped to the id attribute.

  • The nested path.link and path.name keys are mapped to the link and name attributes, respectively.

Validation

General

Lutaml::Model provides a way to validate data models using the validate and validate! methods.

  • The validate method sets an errors array in the model instance that contains all the validation errors. This method is used for checking the validity of the model silently.

  • The validate! method raises a Lutaml::Model::ValidationError that contains all the validation errors. This method is used for forceful validation of the model through raising an error.

Lutaml::Model supports the following validation methods:

  • collection:: Validates collection size range.

  • values:: Validates the value of an attribute from a set of fixed values.

The following class will validate the degree_settings attribute to ensure that it has at least one element and that the description attribute is one of the values in the set [one, two, three].

class Klin < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :degree_settings, :integer, collection: (1..)
  attribute :description, :string, values: %w[one two three]

  xml do
    map_element 'name', to: :name
    map_attribute 'degree_settings', to: :degree_settings
  end
end

klin = Klin.new(name: "Klin", degree_settings: [100, 200, 300], description: "one")
klin.validate
# => []

klin = Klin.new(name: "Klin", degree_settings: [], description: "four")
klin.validate
# => [
#      #<Lutaml::Model::CollectionSizeError: degree_settings must have at least 1 element>,
#      #<Lutaml::Model::ValueError: description must be one of [one, two, three]>
#    ]

e = klin.validate!
# => Lutaml::Model::ValidationError: [
#      degree_settings must have at least 1 element,
#      description must be one of [one, two, three]
#    ]
e.errors
# => [
#     #<Lutaml::Model::CollectionSizeError: degree_settings must have at least 1 element>,
#     #<Lutaml::Model::ValueError: description must be one of [one, two, three]>
#   ]

Custom validation

To add custom validation, override the validate method in the model class. Additional errors should be added to the errors array.

The following class validates the degree_settings attribute when the type is glass to ensure that the value is less than 1300.

class Klin < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :type, :string, values: %w[glass ceramic]
  attribute :degree_settings, :integer, collection: (1..)

  def validate
    errors = super
    if type == "glass" && degree_settings.any? { |d| d > 1300 }
      errors << Lutaml::Model::Error.new("Degree settings for glass must be less than 1300")
    end
  end
end

klin = Klin.new(name: "Klin", type: "glass", degree_settings: [100, 200, 1400])
klin.validate
# => [#<Lutaml::Model::Error: Degree settings for glass must be less than 1300>]

Adapters

General

Lutaml::Model uses an adapter pattern to support multiple libraries for each serialization format.

You will need to specify the configuration for the adapter you want to use. The easiest way is to copy and paste the following configuration into your code.

The configuration is as follows:

require 'lutaml/model'
require 'lutaml/model/xml_adapter/nokogiri_adapter'
require 'lutaml/model/json_adapter/standard_json_adapter'
require 'lutaml/model/toml_adapter/toml_rb_adapter'
require 'lutaml/model/yaml_adapter/standard_yaml_adapter'

Lutaml::Model::Config.configure do |config|
  config.xml_adapter = Lutaml::Model::XmlAdapter::NokogiriAdapter
  config.yaml_adapter = Lutaml::Model::YamlAdapter::StandardYamlAdapter
  config.json_adapter = Lutaml::Model::JsonAdapter::StandardJsonAdapter
  config.toml_adapter = Lutaml::Model::TomlAdapter::TomlRbAdapter
end

You can also provide the adapter type by using symbols like

require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  config.xml_adapter_type = :nokogiri # can be one of [:nokogiri, :ox, :oga]
  config.yaml_adapter_type = :standard_yaml
  config.json_adapter_type = :standard_json # can be one of [:standard_json, :multi_json]
  config.toml_adapter_type = :toml_rb # can be one of [:toml_rb, :tomlib]
end
Note
By default yaml_adapter_type and json_adapter_type are set to :standard_yaml and :standard_json respectively.

XML

Lutaml::Model supports the following XML adapters:

  • Nokogiri (default)

  • Oga (optional, plain Ruby suitable for Opal/JS)

  • Ox (optional)

Using the Nokogiri XML adapter
require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  require 'lutaml/model/xml_adapter/nokogiri_adapter'
  config.xml_adapter = Lutaml::Model::XmlAdapter::NokogiriAdapter
end
Using the Oga XML adapter
require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  require 'lutaml/model/xml_adapter/oga_adapter'
  config.xml_adapter = Lutaml::Model::XmlAdapter::OgaAdapter
end
Using the Ox XML adapter
require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  require 'lutaml/model/xml_adapter/ox_adapter'
  config.xml_adapter = Lutaml::Model::XmlAdapter::OxAdapter
end

YAML

Lutaml::Model supports only one YAML adapter.

  • YAML (default)

Using the YAML adapter
require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  require 'lutaml/model/yaml_adapter/standard_yaml_adapter'
  config.yaml_adapter = Lutaml::Model::YamlAdapter::StandardYamlAdapter
end

JSON

Lutaml::Model supports the following JSON adapters:

  • JSON (default)

  • MultiJson (optional)

Using the JSON adapter
require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  require 'lutaml/model/json_adapter/standard_json_adapter'
  config.json_adapter = Lutaml::Model::JsonAdapter::StandardJsonAdapter
end
Using the MultiJson adapter
require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  require 'lutaml/model/json_adapter/multi_json_adapter'
  config.json_adapter = Lutaml::Model::JsonAdapter::MultiJsonAdapter
end

TOML

Lutaml::Model supports the following TOML adapters:

  • Toml-rb (default)

  • Tomlib (optional)

Using the Toml-rb adapter
require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  require 'lutaml/model/toml_adapter/toml_rb_adapter'
  config.toml_adapter = Lutaml::Model::TomlAdapter::TomlRbAdapter
end
Using the Tomlib adapter
require 'lutaml/model'

Lutaml::Model::Config.configure do |config|
  config.toml_adapter = Lutaml::Model::TomlAdapter::TomlibAdapter
  require 'lutaml/model/toml_adapter/tomlib_adapter'
end

Comparison with Shale

Lutaml::Model is a serialization library that is similar to Shale, but with some differences in implementation.

Feature Lutaml::Model Shale Notes

Data model definition

2 types:

  • Inherit from Shale::Mapper

  • Custom model class

Value types

Lutaml::Model::Type includes: Integer, String, Float, Boolean, Date, DateTime, Time, Decimal, Hash.

Shale::Type includes: Integer, String, Float, Boolean, Date, Time.

Lutaml::Model supports additional value types Decimal, DateTime and Hash.

Configuration

Lutaml::Model::Config

Shale.{type}_adapter

Lutaml::Model uses a configuration block to set the serialization adapters.

Custom serialization methods

:with, on individual attributes

:using, on entire object/document

Lutaml::Model uses the :with keyword for custom serialization methods.

Serialization formats

XML, YAML, JSON, TOML

XML, YAML, JSON, TOML, CSV

Lutaml::Model does not support CSV.

Validation

Supports collection range, fixed values, and custom validation

Requires implementation

Adapter support

XML (Nokogiri, Ox, Oga), YAML, JSON (JSON, MultiJson), TOML (Toml-rb, Tomlib)

XML (Nokogiri, Ox), YAML, JSON (JSON, MultiJson), TOML (Toml-rb, Tomlib), CSV

Lutaml::Model does not support CSV.

XML features

Yes. Supports <root xmlns='http://example.com'> through the namespace option without prefix.

No. Only supports <root xmlns:prefix='http://example.com'>.

XML mixed content support

Yes. Supports the following kind of XML through mixed content support.

<description>My name is
<bold>John Doe</bold>,
and I'm <i>28</i>
years old</description>

No. Shale’s map_content only supports the first text node.

XML namespace inheritance

Yes. Supports the inherit option to inherit the namespace from the root element.

No.

Support for xsi:schemaLocation

Yes. Automatically supports the xsi:schemaLocation attribute for every element.

Requires manual specification on every XML element that uses it.

Attribute features

Attribute delegation

:delegate option to delegate attribute mappings to a model.

:receiver option to delegate attribute mappings to a model.

Enumerations

Yes. Supports enumerations as value types through the values: option.

No.

Lutaml::Model supports enumerations as value types.

Attribute extraction

Yes. Supports attribute extraction from key-value data models.

No.

Lutaml::Model supports attribute extraction from key-value data models.

Migration steps from Shale

The following sections provide a guide for migrating from Shale to Lutaml::Model.

Step 1: Replace inheritance class

Lutaml::Model uses Lutaml::Model::Serializable as the base inheritance class.

class Example < Lutaml::Model::Serializable
  # ...
end
Note

Lutaml::Model also supports an inclusion method as in the following example, which is not supported by Shale. This is useful for cases where you want to include the serialization methods in a class that already inherits from another class.

class Example
  include Lutaml::Model::Serialize
  # ...
end

Shale uses Shale::Mapper as the base inheritance class.

class Example < Shale::Mapper
  # ...
end

Actions:

  • Replace mentions of Shale::Mapper with Lutaml::Model::Serializable.

  • Potentially replace inheritance with inclusion for suitable cases.

Step 2: Replace value type definitions

Value types in Lutaml::Model are under the Lutaml::Model::Type module, or use the LutaML type symbols.

class Example < Lutaml::Model::Serializable
  attribute :length, :integer
  attribute :description, :string
end
Note

Lutaml::Model supports specifying predefined value types as strings or symbols, which is not supported by Shale.

class Example < Lutaml::Model::Serializable
  attribute :length, Lutaml::Model::Type::Integer
  attribute :description, "String"
end

Value types in Shale are under the Shale::Type module.

class Example < Shale::Mapper
  attribute :length, Shale::Type::Integer
  attribute :description, Shale::Type::String
end

Action:

  • Replace mentions of Shale::Type with Lutaml::Model::Type.

  • Potentially replace value type definitions with strings or symbols.

Step 3: Configure serialization adapters

Lutaml::Model uses a configuration block to set the serialization adapters.

require 'lutaml/model/xml_adapter/nokogiri_adapter'
Lutaml::Model::Config.configure do |config|
  config.xml_adapter = Lutaml::Model::XmlAdapter::NokogiriAdapter
end

The equivalent for Shale is this:

require 'shale/adapter/nokogiri'
Shale.xml_adapter = Shale::Adapter::Nokogiri

Here are places that this code may reside at:

  • If your code is a standalone Ruby script, this code will be present in your code.

  • If your code is organized in a Ruby gem, this code will be specified somewhere referenced by lib/your_gem_name.rb.

  • If your code contains tests or specs, they will be in the test setup file, e.g. RSpec spec/spec_helper.rb.

Actions:

  • Replace the Shale configuration block with the Lutaml::Model::Config configuration block.

  • Replace the Shale adapter with the Lutaml::Model adapter.

Step 4: Rewrite custom serialization methods

There is an implementation difference between Lutaml::Model and Shale for custom serialization methods.

Custom serialization methods in Lutaml::Model map to individual attributes.

For custom serialization methods, Lutaml::Model uses the :with keyword instead of the :using keyword used by Shale.

class Example < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :size, :integer
  attribute :color, :string
  attribute :description, :string

  json do
    map "name", to: :name, with: { to: :name_to_json, from: :name_from_json }
    map "size", to: :size
    map "color", to: :color,
                 with: { to: :color_to_json, from: :color_from_json }
    map "description", to: :description,
                       with: { to: :description_to_json, from: :description_from_json }
  end

  xml do
    root "CustomSerialization"
    map_element "Name", to: :name,
                        with: { to: :name_to_xml, from: :name_from_xml }
    map_attribute "Size", to: :size
    map_element "Color", to: :color,
                         with: { to: :color_to_xml, from: :color_from_xml }
    map_content to: :description,
                with: { to: :description_to_xml,
                        from: :description_from_xml }
  end

  def name_to_json(model, doc)
    doc["name"] = "JSON Masterpiece: #{model.name}"
  end

  def name_from_json(model, value)
    model.name = value.sub(/^JSON Masterpiece: /, "")
  end

  def color_to_json(model, doc)
    doc["color"] = model.color.upcase
  end

  def color_from_json(model, value)
    model.color = value.downcase
  end

  def description_to_json(model, doc)
    doc["description"] = "JSON Description: #{model.description}"
  end

  def description_from_json(model, value)
    model.description = value.sub(/^JSON Description: /, "")
  end

  def name_to_xml(model, parent, doc)
    el = doc.create_element("Name")
    doc.add_text(el, "XML Masterpiece: #{model.name}")
    doc.add_element(parent, el)
  end

  def name_from_xml(model, value)
    model.name = value.sub(/^XML Masterpiece: /, "")
  end

  def color_to_xml(model, parent, doc)
    color_element = doc.create_element("Color")
    doc.add_text(color_element, model.color.upcase)
    doc.add_element(parent, color_element)
  end

  def color_from_xml(model, value)
    model.color = value.downcase
  end

  def description_to_xml(model, parent, doc)
    doc.add_text(parent, "XML Description: #{model.description}")
  end

  def description_from_xml(model, value)
    model.description = value.join.strip.sub(/^XML Description: /, "")
  end
end

Custom serialization methods in Shale do not map to specific attributes, but allow the user to specify where the data goes.

class Example < Shale::Mapper
  attribute :name, Shale::Type::String
  attribute :size, Shale::Type::Integer
  attribute :color, Shale::Type::String
  attribute :description, Shale::Type::String

  json do
    map "name", using: { from: :name_from_json, to: :name_to_json }
    map "size", to: :size
    map "color", using: { from: :color_from_json, to: :color_to_json }
    map "description", to: :description, using: { from: :description_from_json, to: :description_to_json }
  end

  xml do
    root "CustomSerialization"
    map_element "Name", using: { from: :name_from_xml, to: :name_to_xml }
    map_attribute "Size", to: :size
    map_element "Color", using: { from: :color_from_xml, to: :color_to_xml }
    map_content to: :description, using: { from: :description_from_xml, to: :description_to_xml }
  end

  def name_to_json(model, doc)
    doc['name'] = "JSON Masterpiece: #{model.name}"
  end

  def name_from_json(model, value)
    model.name = value.sub(/^JSON Masterpiece: /, "")
  end

  def color_to_json(model, doc)
    doc['color'] = model.color.upcase
  end

  def color_from_json(model, doc)
    model.color = doc['color'].downcase
  end

  def description_to_json(model, doc)
    doc['description'] = "JSON Description: #{model.description}"
  end

  def description_from_json(model, doc)
    model.description = doc['description'].sub(/^JSON Description: /, "")
  end

  def name_from_xml(model, node)
    model.name = node.text.sub(/^XML Masterpiece: /, "")
  end

  def name_to_xml(model, parent, doc)
    name_element = doc.create_element('Name')
    doc.add_text(name_element, model.street.to_s)
    doc.add_element(parent, name_element)
  end
end
Note
There are cases where the Shale implementation of custom methods work differently from the Lutaml::Model implementation. In these cases, you will need to adjust the custom methods accordingly.

Actions:

  • Replace the using keyword with the with keyword.

  • Adjust the custom methods.

About LutaML

The name "LutaML" is pronounced as "Looh-tah-mel".

The name "LutaML" comes from the Latin word for clay, "Lutum", and "ML" for "Markup Language". Just as clay can be molded and modeled into beautiful and practical end products, the Lutaml::Model gem is used for data modeling, allowing you to shape and structure your data into useful forms.

This project is licensed under the BSD 2-clause License. See the LICENSE.md file for details.

Copyright Ribose.