Project

stannum

0.0
No release in over a year
A focused library for specifying and validating data structures. Stannum provides tools to define data schemas for domain objects, method arguments, or other structured data and to validate data against and coerce data to the defined schema.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies
 Project Readme

Stannum

A library for defining and validating data structures.

Read The Documentation

Stannum provides a framework-independent toolkit for defining structured data entities and validations. It provides a middle ground between unstructured data (raw Hashes, Structs, or libraries like Hashie) and full frameworks like ActiveModel.

It defines the following objects:

  • Constraints: A validator object that responds to #match, #matches? and #errors_for for a given object.
  • Contracts: A collection of constraints about an object or its properties. Obeys the Constraint interface.
  • Errors: Data object for storing validation errors. Supports arbitrary nesting of errors.
  • Entities: Defines a mutable data object with a specified set of typed attributes.

Why Stannum?

Stannum is not tied to any framework. You can create constraints and contracts to validate Ruby objects and Entities, data structures such as Arrays, Hashes, and Sets, and even framework objects such as ActiveRecord::Models and Mongoid::Documents.

Still, most projects and applications use one framework to handle their data. Why use Stannum constraints?

  • Composability: Because Stannum contracts are their own objects, they can be combined together. Reuse validation logic without duplicating code or defining abstract ancestor classes .
  • Polymorphism: Your data validation is separate from your model definitions. This gives you two major advantages over the traditional approach:
    • You can use the same contract to validate different objects. Do you have a shared concern that cuts across multiple domain objects, such as attaching images, having comments, or creating an audit trail? You can write one contract for the concern and apply that same contract to each applicable model or object.
    • You can use different contracts to validate the same object in different contexts. Need different validations for a regular user versus an admin? Need to handle published articles more strictly than drafts? Need to provide custom validations for each step in your state machine? Stannum has you covered, and because contracts are composable, you can pull in the constraints you need without duplicating your logic.
  • Separation of Concerns: Your data validation is independent from your entities. This means that you can use the same tools to validate anything from controller parameters to models to configuration files.

Compatibility

Stannum is tested against Ruby (MRI) 3.1 through 3.4.

Documentation

Code documentation is generated using YARD, and can be generated locally using the yard gem.

The full documentation is available via GitHub Pages, and includes the code documentation as well as a deeper explanation of Stannum's features and design philosophy. It also includes documentation for prior versions of the gem.

To generate documentation locally, see the SleepingKingStudios::Docs gem.

License

Copyright (c) 2019-2025 Rob Smith

Stannum is released under the MIT License.

Contribute

The canonical repository for this gem is located at https://github.com/sleepingkingstudios/stannum.

To report a bug or submit a feature request, please use the Issue Tracker.

To contribute code, please fork the repository, make the desired updates, and then provide a Pull Request. Pull requests must include appropriate tests for consideration, and all code must be properly formatted.

Code of Conduct

Please note that the Stannum project is released with a Contributor Code of Conduct. By contributing to this project, you agree to abide by its terms.

Getting Started

Let's take a look at using Stannum to model a problem domain. Consider the following case study: we are implementing an ecommerce application. We want to ensure that our Order object is valid through each stage of our ordering process. For the sake of simplicity, let's say our process has three steps:

  • A customer creates an order.
  • The customer is billed for the order.
  • The order is shipped to the customer.

Defining Entities

Our first step is to define some entities to represent our data.

class Customer
  include Stannum::Entity

  define_primary_key :id, Integer

  define_attribute :email,   String
  define_attribute :address, String

  define_association :many, :orders
end

Our Customer class represents a user who can create an order in our system. We define an #id primary key, attributes #email and #address, and a plural association to our Order entity.

class Payment
  include Stannum::Entity

  define_primary_key :id, Integer

  define_attribute :amount, BigDecimal

  define_association :one, :order, foreign_key: true
end

Our Payment class represents a payment submitted by our customer for an order. We again define an #id primary key, as well as an #amount attribute and a singular association to an Order. Note that we are specifying a foreign key for the #order association, which automatically creates an #order_id attribute.

class Order
  include Stannum::Entity

  define_primary_key :id, Integer

  define_attribute :amount, BigDecimal

  define_association :one, :customer, foreign_key: true
  define_association :one, :payment

  constraint :customer, Stannum::Constraints::Presence.new
end

Our core class for this workflow is the Order entity. We define our #id and #amount, and associations to the Customer and Payment entities - again, by passing foreign_key: true to our #customer association, we also define a #customer_id attribute. Finally, we are defining an additional constraint. When validating the Order using the default contract, we will require the #customer association to be populated - an Order must always have an associated Customer. However, we are not requiring the presence of a Payment, since that requirement is not applicable to the entire Order lifecycle.

Defining Validators

Actually implementing the business logic for orders is outside the scope of Stannum - for a structured approach to defining your business logic, take a look at the Cuprum gem.

However, that doesn't mean we're finished. One of the challenges in implementing a multi-step process like our ordering flow is validation. Specifically, this kind of workflow requires contextual validation - an Order object that is valid for one part of the flow may not be valid for others. Rather than defining conditional logic in our Order class, let's instead apply the concept of a Validator: an object that is responsible for validating an entity or data structure in a particular context.

Let's start with our first step, order creation. Creating an order requires a valid #id, a valid #amount (can be zero at this point in the workflow), and an associated #customer. Fortunately, we already have a contract defined for these requirements: the existing Order::Contract, which validates the entity's attributes and any additional constraints defined on the entity.

Here's how we could use that in our business logic:

customer = Customer.new(
  id:      0,
  email:   'user@example.com',
  address: '123 Example St'
)
order = Order.new(id: 1)

Order::Contract.matches?(order)
#=> false
errors = Order::Contract.errors_for(order)
#=> an instance of Stannum::Errors
errors.summary
#=> "amount: is not a BigDecimal, customer: is nil or empty"

order.amount   = BigDecimal('0.0')
order.customer = customer

Order::Contract.matches?(order)
#=> true

If our contract #matches? the order, we proceed with the creation logic. Otherwise, we return an error message, possibly using the errors #summary.

Now we move on to validating that an order is ready for billing. Our validation logic gets more complicated here: in addition to requiring a valid order (the same validations as above), we need to make sure that the billable amount is greater than zero.

One common approach is to add conditional validation to the Order class itself. For example, defining a #status attribute asserting that the #amount is greater than zero if the status matches a value. However, as new cases and conditions are added, this approach quickly becomes difficult to read and reason about. Instead, we're going to define a validator object.

module Orders
  module Contracts
    IS_BILLABLE = Stannum::Contract.new do
      concat(Order::Contract)

      property :amount,
        message: 'must be greater than zero',
        type:    'orders.constraints.greater_than_zero' \
      do |value|
        value.is_a?(Numeric) && value > 0
      end
      property :payment, Stannum::Constraints::Types::NilType.new
    end
  end
end

Our validator is an instance of Stannum::Contract, and our first step is to concat the existing Order::Contract. This means that all of the constraints in the Order::Contract will also be applied when matching an order with the IS_BILLABLE contract. This means we don't need to duplicate our existing constraints.

Second, we are adding a custom constraint on the #amount attribute. Notice that the first check inside the block checks that the value is Numeric; otherwise, comparing a nil value would raise an exception, rather than failing the validation. We are also defining a custom message and type for the constraint. The message is intended to be a human-readable representation of the error, while the type is intended for machines.

Finally, we validate that the #payment association is nil, since we don't want to accidentally bill the same order twice. Here we can see why a validator object is so powerful - we obviously can't add this kind of constraint directly to Order, since orders later in the workflow will clearly not match. However, since our IS_BILLABLE contract applies only to this specific context, we can make the validation logic as specific as we want.

Our final step is to ship the order to the customer. Again, to determine if the order is ready to be shipped, we define a validator object:

module Orders
  module Contracts
    IS_SHIPPABLE = Stannum::Contract.new do
      concat(Order::Contract)

      property :payment, Stannum::Constraints::Presence.new

      property :customer, Stannum::Contract.new {
        property :address, Stannum::Constraints::Presence.new
      }
    end
  end
end

Again, we define a custom Stannum::Contract and concat the existing Order::Contract, and we add a constraint that the #payment association needs to be populated - we don't want to ship an order that hasn't been paid for yet. Next, we define a nested contract to assert that the #customer association has a present #address attribute. You can define complex validation logic easily by composing together multiple constraints and contracts.

Here is how we would use the IS_BILLABLE and IS_SHIPPABLE contracts in our business logic:

customer = Customer.new(
  id:      0,
  email:   'user@example.com',
  address: '123 Example St'
)
order = Order.new(id: 1, customer:)

Orders::Contracts::IS_BILLABLE.matches?(order)
#=> false
errors = Orders::Contracts::IS_BILLABLE.errors_for(order)
errors.summary
#=> "amount: is not a BigDecimal, amount: must be greater than zero"

order.amount = BigDecimal('100.0')
Orders::Contracts::IS_BILLABLE.matches?(order)
#=> true

Orders::Contracts::IS_SHIPPABLE.matches?(order)
#=> false
errors = Orders::Contracts::IS_SHIPPABLE.errors_for(order)
errors.summary
#=> "payment: is nil or empty"

order.payment = Payment.new(id: 2, amount: order.amount)
Orders::Contracts::IS_SHIPPABLE.matches?(order)
#=> true

As we define our BillOrder and ShipOrder classes, we will be able to use our IS_BILLABLE and IS_SHIPPABLE contracts to quickly identify orders that are invalid for that context.

Validating Other Data

In addition to using them with entities, Stannum constraints and contracts can be used to validate almost any sort of data. For example, consider our ordering workflow. Perhaps we make a API call to a company that actually ships the order to the customer. We want to ensure that the API response contains the expected data. We can define a contract to validate the returned JSON body:

SUCCESS_RESPONSE_CONTRACT = Stannum::Contract.new do
  property :status, Stannum::Constraints::Identity.new(200)

  property :body, Stannum::Contracts::HashContract.new {
    key 'ok', Stannum::Constraints::Identity.new(true)

    key 'shipping_confirmation',
      Stannum::Constraint.new(
        message: 'be a string with length 24',
        type:    'orders.constraints.valid_shipping_confirmation'
      ) { |value|
        value.is_a?(String) && value.size == 24
      }
  }
end

We can then validate the API response by calling SUCCESS_RESPONSE_CONTRACT.matches?(response).