Module: Ippon::Validate

Defined in:
lib/ippon/validate.rb

Overview

Ippon::Validate provides a composable validation system which let's you accept untrusted input and process it into trusted data.

Introductory example

# How to structure schemas:
require 'ippon/validate'

module Schemas
  extend Ippon::Validate::Builder

  User = form(
    name: fetch("name") | trim | required,
    email: fetch("email") | trim | optional | match(/@/),
    karma: fetch("karma") | trim | optional | number | match(1..1000),
  )
end

# How to use them:
result = Schemas::User.validate({
  "name" => " Magnus ",
  "email" => "",
  "karma" => "100",
})

result.valid?  # => true
result.value   # =>
  {
    name: "Magnus",
    email: nil,
    karma: 100,
  }

result = Schemas::User.validate({
  "name" => " Magnus ",
  "email" => "bob",
  "karma" => "",
})
result.valid?             # => false
result.errors[0].message  # => "email: must match /@/"

General usage

Most validation libraries has a concept of form which contains multiple fields. In Ippon::Validate there is no such distinction; there is only schemas that you can combine together.

You can think about a schema as a pipeline: You have an untrusted value that's coming in and as it travels through the steps it can be transformed, halted, or produce errors. If the data is well-formed you will end up with a final value that has been correctly parsed and is ready to be used.

Everything you saw in the introductory example was an instance of Schema and thus you can call Schema#validate (or Schema#validate!) on any part or combination:

module Schemas
  extend Ippon::Validate::Builder

  trim.validate!("  123  ")  # => "123"


  (trim | number).validate!("  123 ")  # => 123


  (fetch("age") | number).validate!({"age" => "123"})  # => 123


  form(
    age: fetch("age") | trim | number
  ).validate!({"age" => " 123 "})  # => { age: 123 }

end

Schema#validate will always return a Result object, while Schema#validate! will return the output value, but raise a ValidationError if an error occurs.

Step: The most basic schema

The smallest schema in Ippon::Validate is a Step and you create them with the helper methods in Builder:

module Schemas
  extend Ippon::Validate::Builder

  step = number(
    convert: :round,
    html: { required: true },
    message: "must be numerical",
  )

  step.class           # => Ippon::Validate::Step
  step.type            # => :number
  step.message         # => "must be numerical"
  step.props[:message] # => "must be numerical"
  step.props[:html]    # => { required: true }
end

Every step is configured with a Hash called props. The purpose is for you to be able to include custom data you need in order to present a reasonable error message or form interface. In the example above we have attached a custom :html prop which we intend to use while rendering the form field in HTML. The :message prop is what decides the error message, and the :convert prop is used by the number step internally.

The most general step methods are transform and validate. transform changes the value according to the block, while validate will cause an error if the block returns falsey.

module Schemas
  extend Ippon::Validate::Builder

  is_date = validate { |val| val =~ /^\d{4}-\d{2}-\d{2}$/ }
  to_date = transform { |val| Date.parse(val) }
end

Instead of validate you will often end up using one of the other helper methods:

  • required checks for nil. (We'll cover optional in the next section since it's quite a different beast.)

  • match uses the === operator which allows you to easily match against constants, regexes and classes.

And instead of transform you might find these useful:

  • number (and float) for parsing strings as numbers.

  • boolean for converting to booleans.

  • fetch for fetching a field.

  • trim removes whitespace from the beginning/end and converts it to nil if it's empty.

Combining schemas

You can use the pipe operator to combine two schemas. The resulting schema will first try to apply the left-hand side and then apply the right-hand side. This let's you build quite complex validation rules in a straight forward way:

module Schemas
  extend Ippon::Validate::Builder

  Karma = fetch("karma") | trim | optional | number | match(1..1000)

  Karma.validate!({"karma" => " 500 "}) # => 500
end

A common pattern you will see is the combination of fetch and trim. This will fetch the field from the input value and automatically convert empty-looking fields into nil. Assuming your input is from a text field you most likely want to treat empty text as a nil value.

Halting and optional

Whenever an error is produced the validation is halted. This means that further schemas combined will not be executed. Continuing from the example in the previous section:

result = Schemas::Karma.validate({"karma" => " abc "})
result.error?   # => true
result.halted?  # => true

result.errors.size        # => 1
result.errors[0].message  # => "must be number"

Once the number schema was processed it produced an error and halted the result. Since the result was halted the pipe operator did not apply the right-hand side, match(1..1000). This is good, because there is no number to validate against.

optional is a schema which, if the value is nil, halts without producing an error:

result = Schemas::Karma.validate({"karma" => " "})
result.error?   # => false
result.halted?  # => true

result.value    # => nil

Although we might think about optional as having the meaning “this value can be nil”, it's more precise to think about it as “when the value is nil, don't touch or validate it any further”. required and optional are surprisingly similar with this approach: Both halts the result if the value is nil, but required produces an error in addition.

Building forms

We can use form when we want to validate multiple distinct values in one go:

module Schemas
  extend Ippon::Validate::Builder

  User = form(
    name: fetch("name") | trim | required,
    email: fetch("email") | trim | optional | match(/@/),
    karma: fetch("karma") | trim | optional | number | match(1..1000),
  )

  result = User.validate({
    "name" => " Magnus ",
    "email" => "",
    "karma" => "100",
  })

  result.value   # =>
    {
      name: "Magnus",
      email: nil,
      karma: 100,
    }

  result = User.validate({
    "name" => " Magnus ",
    "email" => "bob",
    "karma" => "",
  })

  result.valid?             # => false

  result.errors[0].message  # => "email: must match /@/"
end

It's important to know that the keys of the form doesn't dictate anything about the keys in the input data. You must explicitly use fetch if you want to access a specific field. At first this might seem like unneccesary duplication, but this is a crucical feature in order to decouple the input data from the output data. Often you'll find it useful to be able to rename internal identifiers without breaking the forms, or you'll find that the form data doesn't match perfectly with the internal data model.

Here's an example for how you can write a schema which accepts a single string and then splits it up into a title (the first line) and a body (the rest of the text):

module Schemas
  extend Ippon::Validate::Builder

  Post = form(
    title: transform { |val| val[/\A.*/] } | required,
    body: transform { |val| val[/\n.*\z/m] } | trim,
  )

  Post.validate!("Hello")  # => { title: "Hello", body: nil }


  Post.validate!("Hello\n\nTesting")  # => { title: "Hello", body: "Testing" }

end

This might seem like a contrived example, but the purpose here is to show that no matter how complicated the input data is Ippon::Validate will be able to handle it. The implementation might not look very nice, but you will be able to integrate it into your regular schemas without writing a separate “clean up input” phase.

In addition there is the merge operator for merging two forms. This is useful when the same fields are used in multiple forms, or if the fields available depends on context (e.g. admins might have access to edit more fields).

module Schemas
  extend Ippon::Validate::Builder

  Basic = form(
    username: fetch("username") | trim | required,
  )

  Advanced = form(
    karma: fetch("karma") | optional | number,
  )

  Both = Basic & Advanced
end

Partial forms

At first the following example might look a bit confusing:

module Schemas
  extend Ippon::Validate::Builder

  User = form(
    name: fetch("name") | trim | required,
    email: fetch("email") | trim | optional | match(/@/),
    karma: fetch("karma") | trim | optional | number | match(1..1000),
  )

  result = User.validate({
    "name" => " Magnus ",
  })

  result.error? # => true

  result.errors[0].message  # => "email: must be present"
end

We've marked the :email field as optional, yet it seems to be required by the schema. This is because all fields of a form must be present. The optional schema allows the value to take a nil value, but it must still be present in the input data.

When you declare a form with :name, :email and :karma, you are guaranteed that the output value will always contain :name, :email and :karma. This is a crucial feature so you can always trust the output data. If you misspell the email field as “emial” you will get a validation error early on instead of the data magically not appearing in the output data (or it being set to nil).

There are some use-cases where you want to be so strict about the presence of fields. For instance, you might have an endpoint for updating some of the fields of a user. For this case, you can use a partial_form:

module Schemas
  extend Ippon::Validate::Builder

  User = partial_form(
    name: fetch("name") | trim | required,
    email: fetch("email") | trim | optional | match(/@/),
    karma: fetch("karma") | trim | optional | number | match(1..1000),
  )

  result = User.validate({
    "name" => " Magnus ",
  })

  result.valid? # => true
  result.value  # => { name: "Magnus" }

  result = User.validate({
  })

  result.valid? # => true
  result.value  # => {}
end

partial_form works similar to form, but if there's a fetch validation error for a field, it will be ignored in the output data.

Working with lists

If your input data is an array you can use for_each to validate every element:

module Schemas
  extend Ippon::Validate::Builder

  User = form(
    username: fetch("username") | trim | required,
  )

  Users = for_each(User)

  result = Users.validate([{"username" => "a"}, {"username" => "  "}])
  result.error?  # => true
  result.errors[0].message  # => "1.username: is required"
end

Defined Under Namespace

Modules: Builder Classes: ForEach, Form, Merge, NestedError, Result, Schema, Sequence, Step, StepError, Unhalt, ValidationError