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]. # => "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. # => "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:
-
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]. # => "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]. # => "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]. # => "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]. # => "1.username: is required"
end
Defined Under Namespace
Modules: Builder Classes: Errors, ForEach, Form, Merge, Result, Schema, Sequence, Step, Unhalt, ValidationError
Constant Summary collapse
- StepError =
Value used to represent that an error has occured.
Object.new.freeze
- EMPTY_ERRORS =
An
Errors
object which is empty and immutable. Errors.new.deep_freeze