General

Lutaml::Model provides comprehensive validation for data models using the validate and validate! methods.

  • The validate method returns an errors array containing all 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.

Validation types

Lutaml::Model supports the following validation types:

Collection validation

The collection option sets an attribute’s cardinality: collection: true means 0..*, a range such as (1..) or (0..5) bounds the count, and omitting it (the default) means 0..1, a single value.

attribute :items, :string, collection: (1..)  # At least one item
attribute :tags, :string, collection: (0..5)  # Up to 5 tags
attribute :name, :string                      # Default 0..1 (single value)

A non-collection attribute given multiple values keeps them. Parsing does not raise; the values land in the attribute as an array and #validate reports Lutaml::Model::CollectionTrueMissingError, which #validate! raises wrapped in a Lutaml::Model::ValidationError.

obj = Person.from_xml("<person><name>A</name><name>B</name></person>")
obj.name      # => ["A", "B"]
obj.validate  # => [#<Lutaml::Model::CollectionTrueMissingError ...>]
obj.validate! # raises Lutaml::Model::ValidationError

Declare collection: true for attributes that hold many values.

The rule is about shape, not count. A singular attribute is declared to hold a value, not a list, so a one-element array is still a violation:

Person.from_json('{"name": ["A"]}').validate
# => [#<Lutaml::Model::CollectionTrueMissingError ...>]

XML has no such distinction — a single <name> element is a value, so only a repeated element trips the check.

An invalid document does not round-trip. While an attribute holds more values than it was declared for, serializing it back out will not reproduce the input. Fix the violation and it round-trips normally.

Two cases still report at parse time rather than on #validate:

  • A declared range that is exceeded raises Lutaml::Model::CollectionCountOutOfRangeError for key/value formats.

  • A model mapped onto a plain Ruby class with model has no #validate, so there is no later point to report to.

A custom deserializer (with: { from: }) receives whatever arrived — a single node for one occurrence, an array for several, the same shape a custom to: method already receives for a collection. If that method collapses the array to a single value, the over-count is not reported: the method has decided what the attribute holds.

Value enumeration validation

The values option validates that an attribute value is one of a fixed set of values.

attribute :status, :string, values: %w[draft published archived]

Choice validation

The choice directive validates that attributes within a defined range are present.

choice(min: 1, max: 2) do
  attribute :email, :string
  attribute :phone, :string
end

Pattern validation

The pattern option validates string values against regular expressions.

attribute :email, :string, pattern: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i

Required attributes

The required: true option validates that an attribute is present.

attribute :name, :string, required: true

XML schema validation

The validate_xml_with class macro validates the model’s generated XML against one or more XML Schema (XSD) files during validate / validate!.

class Person < Lutaml::Model::Serializable
  attribute :name, :string

  xml do
    root "person"
    map_element "name", to: :name
  end

  validate_xml_with "schemas/person.xsd"
end

person = Person.new(name: "Alice")
person.validate   # => collects Lutaml::Xml::Error::SchemaValidationError
                  #    instances when the generated XML does not conform
person.validate!  # => raises Lutaml::Model::ValidationError on nonconformance

The macro accepts one or more schema paths, and repeated calls append to the configured list. Subclasses inherit the parent’s schema paths parent-first; paths added by a subclass do not affect the parent.

validate_xml_with "schemas/base.xsd", "schemas/extensions.xsd"

Raw XML strings can be checked against the same schemas with the explicit class-level helpers, for example to reject nonconforming input before from_xml:

Person.validate_xml(xml_string)   # => array of schema validation errors
Person.validate_xml!(xml_string)  # => raises Lutaml::Model::ValidationError
to_xml and from_xml never validate implicitly. Call validate! before serializing, or validate_xml! on incoming XML, when enforcement is needed — the same explicit contract as every other validation type.

XSD validation uses Nokogiri, which is required lazily on first validation. The schemas must be local files declared explicitly; xsi:schemaLocation in documents is not read and remote schemas are not fetched. Relative paths resolve against the file that declares validate_xml_with, so a model works regardless of the process working directory.

Misconfiguration raises instead of being collected as validation errors:

  • a missing schema file raises Errno::ENOENT;

  • a malformed XSD raises Nokogiri::XML::SyntaxError;

  • a malformed XML string passed to validate_xml / validate_xml! raises Nokogiri::XML::SyntaxError — parsing is strict, so malformed input is never silently repaired and reported as conforming;

  • a missing nokogiri gem raises Lutaml::Xml::Error::XmlConfigurationError;

  • a class using the macro without an XML root mapping raises Lutaml::Model::TypeOnlyMappingError from validate, since validation must serialize the model to check it.

Validation examples

The following class will validate the name is present and degree_settings attributes 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, required: true
  attribute :degree_settings, :integer, collection: (1..)
  attribute :description, :string, values: %w[one two three]
  attribute :id, :integer
  attribute :age, :integer

  choice(min: 1, max: 1) do
    choice(min: 1, max: 2) do
      attribute :prefix, :string
      attribute :forename, :string
    end

    attribute :nick_name, :string
  end

  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", prefix: "Ben")
klin.validate
# => []

klin = Klin.new(name: "Klin", degree_settings: [], description: "four", prefix: "Ben", nick_name: "Smith")
klin.validate
# => [
#      #<Lutaml::Model::CollectionSizeError: degree_settings must have at least 1 element>,
#      #<Lutaml::Model::ValueError: description must be one of [one, two, three]>,
#      #<Lutaml::Model::ChoiceUpperBoundError: Attribute count exceeds the upper bound>
#    ]

e = klin.validate!
# => Lutaml::Model::ValidationError: [
#      degree_settings must have at least 1 element,
#      description must be one of [one, two, three],
#      Attribute count exceeds the upper bound
#    ]
e.errors
# => [
#     #<Lutaml::Model::CollectionSizeError: degree_settings must have at least 1 element>,
#     #<Lutaml::Model::ValueError: description must be one of [one, two, three]>,
#     #<Lutaml::Model::ChoiceUpperBoundError: Attribute count exceeds the upper bound>
#     #<Lutaml::Model::ChoiceLowerBoundError: Attribute count is less than lower bound>
#   ]

klin = Klin.new(degree_settings: [100, 200, 300], description: "one", prefix: "Ben")
klin.validate
# => [
#      #<Lutaml::Model::RequiredAttributeMissingError: Missing required attribute: name>
#    ]

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>]