01ec9a945ae5bb0e2a776740d0d19aaf

Validation outside ActiveModel

On the latest Euruko Ruby Conference (Bulgaria, Sofia, 2016) I had a lightning talk about a different approach to data verification. Today, I want to share and explain more of the good stuff in validation.

In a Ruby on Rails application, we use validation mostly for models:

class User < ApplicationRecord
  validates :name, presence: true
end

or forms:

class UserForm
  include ActiveModel::Model

  attr_accessor :name

  validates :name, presence: true
end

There are cases when the application isn't saving data in any database, nor is any data received from a form on a web page, but we still need to perform validations. One example could be sending SMS - before we do it, we need to make sure we're sending correct data.

class UserContact
  include ActiveModel::Validations

  attr_accessor :name, :phone_number, :message

  validates :name, presence: true
  validates :phone_number, numericality: { only_integer: true }
  validates :message, presence: true
end

contact = UserContact.new
contact.name = "John Doe"
contact.phone_number = 123456789
contact.valid? # => false
contact.errors # => { message: ["can't be blank"] }

Validating without Rails

For one of our clients, we have decided to build a single page application with API without using Ruby on Rails. This approach allowed us to decrease response time from API. We used Roda for routing, ROM (Ruby Object Mapper) as an access layer to the database and dry-validation for validation.

ROM is a small layer between a database adapter and application. ROM doesn't have validation layer. We used dry-validation gem which provides a lot of fancy validation rules.

Dry-validation is designed to validate any data stored in a hash. It forces us to focus on input data more carefully when validating model attributes. On the other hand, it makes us more aware of the input data.

Invoking a validation

Validation in dry-validation gem looks a bit different than model oriented validation.

input_data = {
  name: "John Doe",
  phone_number: 123456789
}

UserContactSchema = Dry::Validation.Schema do
  required(:name) { filled? }
  required(:phone_number) { filled? & nil? }
end

result = UserContactSchema.call(input_data)
result.success? # => true
result.failure? # => false
result.errors # => { message: ["must be filled"] }

In dry-validation, we can also write

required(:name).filled

which is equal to

required(:name) { filled? }

Optional key and value

In models, we predefine attributes in class implementation. We make sure that object will have a filled attribute or an attribute with nil value. In a hash, we have a little more complicated situation, a key can be set or not. Firstly, take a look on hash without name key:

{
  age: 30
}

Let's write validation rules:

Dry::Validation.Schema do
  required(:age) { filled? & int? & gt?(18) }
end

Now, we will extend hash with name key:

{
  name: "John Doe",
  age: 30
}

New validation schema should handle optional key:

Dry::Validation.Schema do
  optional(:name) { filled? }
  required(:age) { filled? & int? & gt?(18) }
end

Sometimes, when data is sent from the outside world it can contain nil value. For example, someone uses a model-based framework and the application sends keys without values.

{
  name: "John Doe",
  age: nil
}

When we decide to allow nil value for some attributes we must handle it in schema:

Dry::Validation.Schema do
  optional(:name} { filled? }
  required(:age) { none? | (filled? & int? & gt?(18)) }
end

Nested data

Let's go back to our user's contact hash:

input_data = {
  name: "John Doe",
  phone_number: 123456789
}

We can assume that data will be sending to API in JSON API standard. In this case, the params will look like this:

{
  data: {
    attributes: {
      name: "John Doe",
      phone_number: 123456789
    }
  }
}

Schema with attributes nested in hash isn't so complicated:

Dry::Validation.Schema do
  require(:data).schema do
    require(:attributes).schema do
      optional(:name) { filled? }
      required(:phone_number) { filled? & int? }
    end
  end
end

Next great thing is validating an array:

input_data = {
  data: {
    attributes: {
      name: "John Doe",
      phone_number: 123456789,
      emails: ["[email protected]", "foobar"]
    }
  }
}

And validation schema:

schema = Dry::Validation.Schema do
  require(:data).schema do
    require(:attributes).schema do
      optional(:name) { filled? }
      required(:phone_number) { filled? & int? }
      optional(:emails).each(:email?)
    end
  end
end

puts schema.call(input_data).message
# {
#   emails: {
#     1 => ["must be valid email address"]
#   }
# }

NOTICE: In the example above can you see :email? symbol. This symbol will invoke email? predicate which isn't available in dry-validation. Don’t worry I will show you how to implement your own predicate in the next post. Subscribe to our newsletter and wait for next part.

Shared schemas

Sometimes we repeat the same data structure like an address on an invoice, customer address and delivery address. With dry-validation, we can create validation for address and attach it many times.

data = {
  customer_address: {
    street: "Main Street",
    city: "New York"
  },
  delivery_address: {
    street: "Brodway"
  }
}

We can define only one schema:

AddressSchema = Dry::Validation.Schema do
  required(:street).filled
  required(:city).filled
end

with already prepared schema we can use it twice:

invoice_schema = InvoiceSchema = Dry::Validation.Schema do
  required(:customer_address).schema(AddressSchema)
  required(:delivery_address).schema(AddressSchema)
end

Let's check how it will behave:

data = {
  customer_address: {
    street: "Main Street",
    city: "New York"
  },
  delivery_address: {
    street: "Brodway"
  }
}

puts invoice_schema.call(data).messages
# {
#   delivery_address: {
#     city: ["is missing"]
#   }
# }

If you don't use ActiveRecord/ActiveModel/Ruby on Rails or you need to verify something different than a class with attributes, you should try dry-validation.

At Ragnarson we help companies deliver great products. We take care of development and deployment so that they can focus on growing the product and working with customers.

Work with us