Current section
Files
Jump to
Current section
Files
lib/saul.ex
defmodule Saul do
@moduledoc """
Contains the core of the functionality provided by Saul.
Saul is a data validation and conformation library. It tries to solve the
problem of validating the shape and content of some data (most useful when
such data come from an external source) and of conforming those data to
arbitrary formats.
Saul is based on the concept of **validators**: a validator is something that
knows how to validate a term and transform it to something else if
necessary. A good example of a validator could be something that validates
that a term is a string representation of an integer and that converts such
string to the represented integer.
Validators are a powerful abstraction as they can be easily *combined*: for
example, the `Saul.one_of/1` function takes a list of validators and returns a
validator that passes if one of the given validators pass. Saul provides both
"basic" validators as well as validator combinators.
## Validators
A validator can be:
* a function that takes one argument
* a term that implements the `Saul.Validator` protocol
The return value of function validators or implementations of
`Saul.Validator.validate/2` has to be one of the following:
* `{:ok, transformed}` - it means validation succeeded (the input term is
considered valid) and `transformed` is the conformed value for the input
term.
* `{:error, reason}` - it means validation failed (the input term is
invalid). `reason` can be any term: if it is not a `Saul.Error` struct,
`validate/2` will take care of wrapping it into a `Saul.Error`.
* `true` - it means validation succeeded. It is the same as `{:ok,
transformed}`, but it can be used when the transformed value is the same
as the input value. This is useful for "predicate" validators (functions
that take one argument and return a boolean).
* `false` - it means validation failed. It is the same as `{:error, reason}`,
except the reason only mentions that a "predicate failed".
Returning a boolean value is supported so that existing predicate functions
can be used as validators without modification. Examples of such functions are
type guards (`is_binary/1` or `is_list/1`), functions like `String.valid?/1`,
and many others.
## Validating
The only entry point for validation is `validate/2`. It hides all the
complexity of the possible return values of validators (described in the
"Validators" section) and always returns `{:ok, transformed}` (where
`transformed` can be the same term as the term being validated) or `{:error,
%Saul.Error{}}`. See the documentation for `validate/2` for more detailed
documentation.
"""
@typedoc """
The type defining a validator.
See the module documentation for more information on what are validators.
"""
@type validator(transformed_type) ::
(term -> {:ok, transformed_type} | {:error, term})
| (term -> boolean)
| Saul.Validator.t
@doc """
Validates the given `term` through the given `validator`.
If the validator successfully matches `term`, then the return value of this
function is `{:ok, transformed}` where `transformed` is the result of the
transformation applied by the validator. If the validator returns `{:error,
reason}`, the return value of this function is `{:error, %Saul.Error{}}`.
Note that the given validator can return any type of `reason` when returning
an `:error` tuple: `validate/2` will take care of wrapping it into a
`%Saul.Error{}`. This is done so that users can work with a consistent
interface but at the same time they can use already existing functions as
validators (since `{:ok, term} | {:error, term}` is quite a common API in
Erlang/Elixir).
## Examples
iex> to_string = &{:ok, to_string(&1)}
iex> Saul.validate(:foo, to_string)
{:ok, "foo"}
iex> Saul.validate("hello", to_string)
{:ok, "hello"}
iex> failer = fn(_) -> {:error, :bad} end
iex> {:error, %Saul.Error{} = error} = Saul.validate(3.14, failer)
iex> error.reason
":bad"
"""
@spec validate(term, validator(value)) ::
{:ok, value} | {:error, Saul.Error.t} | no_return when value: term
def validate(term, validator) do
result =
case validator do
fun when is_function(fun, 1) -> validator.(term)
_ -> Saul.Validator.validate(validator, term)
end
case result do
{:ok, _transformed} = result ->
result
true ->
{:ok, term}
{:error, %Saul.Error{}} = result ->
result
{:error, reason} ->
{:error, %Saul.Error{validator: validator, reason: inspect(reason), term: {:term, term}}}
false ->
{:error, %Saul.Error{validator: validator, reason: "predicate failed", term: {:term, term}}}
other ->
raise ArgumentError, "validator should return {:ok, term}, {:error, term}, " <>
"or a boolean, got: #{inspect(other)}"
end
end
@doc """
Validates the given `term` through the given `validator`, raising in case of errors.
This function works like `validate/2`, but it returns the transformed term
directly in case validation succeeds or raises a `Saul.Error` exception in
case validation fails.
## Examples
iex> Saul.validate!("foo", &is_binary/1)
"foo"
iex> Saul.validate!("foo", &is_atom/1)
** (Saul.Error) (&:erlang.is_atom/1) predicate failed - failing term: "foo"
"""
@spec validate!(term, validator(value)) :: value | no_return when value: any
def validate!(term, validator) do
case validate(term, validator) do
{:ok, transformed} ->
transformed
{:error, %Saul.Error{} = error} ->
raise(error)
end
end
## Validators
@doc """
Returns a validator that performs the same validation as `validator` but has
the name `name`.
This function is useful in order to have better errors when validation
fails. In such cases, the name of each of the failing validators is printed
alongside the error. If your validator is an anonymous function `f`, such name
will be `inspect(f)`, so it won't be very useful when trying to understand
errors. Naming a validator is also useful when your validator is an isolated
logical unit (such as a validator that validates that a term is an integer,
positive, and converts it to its Roman representation).
## Examples
iex> failer = Saul.named_validator(fn(_) -> {:error, :oops} end, "validator that always fails")
iex> Saul.validate!(:foo, failer)
** (Saul.Error) (validator that always fails) :oops - failing term: :foo
"""
@spec named_validator(validator(value), String.t) :: validator(value) when value: any
def named_validator(validator, name) do
%Saul.Validator.NamedValidator{name: name, validator: validator}
end
@doc """
Returns a validator that always passes and applies the given transformation
`fun`.
This function is useful when a validator is only applying a transformation,
and not performing any validation. Using this function is only beneficial
inside more complex validators, such as `all_of/1`, where `fun` needs to have
the shape of a validator. For other cases, you can just apply `fun` directly
to the input term.
## Examples
For example, if you validated that a term is a binary in some way, but want to
transform it to a charlist during validation, you could wrap
`String.to_charlist/1` inside `transform/1`:
iex> term = "this is a string"
iex> Saul.validate!(term, Saul.transform(&String.to_charlist/1))
'this is a string'
"""
@spec transform((input -> output)) :: (input -> {:ok, output}) when input: var, output: var
def transform(fun) when is_function(fun, 1) do
&{:ok, fun.(&1)}
end
@doc """
Returns a validator that checks that the input term is equal to `term`.
This is a basic validator that allows to check for literal terms (hence its
name, "lit"). If the input term is equal to `term`, then it is returned
unchanged.
## Examples
iex> three = Saul.lit(3)
iex> Saul.validate(3, three)
{:ok, 3}
iex> {:error, error} = Saul.validate(4, three)
iex> error.reason
"expected exact term 3"
"""
@spec lit(value) :: validator(value) when value: term
def lit(term) do
%Saul.Validator.Literal{term: term}
end
@doc """
Returns a validator that matches when all the given `validators` match.
`validators` has to be a *non-empty* list of validators.
The validation stops and fails as soon as one of the `validators` fails, or
succeeds and returns the value returned by the last validator if all
validators succeed. When a validator succeeds, the transformed value it
returns is passed as the input to the next validator in the list: this allows
to simulate a "pipeline" of transformations that halts as soon as something
doesn't match (similar to a small subset of what you could achieve with the
`with` Elixir special form).
## Examples
iex> validator = Saul.all_of([&{:ok, to_string(&1)}, &is_binary/1])
iex> Saul.validate(:hello, validator)
{:ok, "hello"}
iex> validator = Saul.all_of([&is_binary/1, &{:ok, &1}])
iex> Saul.validate!(:hello, validator)
** (Saul.Error) (&:erlang.is_binary/1) predicate failed - failing term: :hello
"""
@spec all_of(nonempty_list(validator(term))) :: validator(term)
def all_of([_ | _] = validators) do
%Saul.Validator.AllOf{validators: validators}
end
@doc """
Returns a validator that matches if one of the given `validators` match.
`validators` has to be a *non-empty* list of validators.
The validation stops and succeeds as soon as one of the `validators`
succeeds. The value returned by the succeeding validator is the value returned
by this validator as well. If all validators fail, an error that shows all the
failures is returned.
## Examples
iex> validator = Saul.one_of([&is_binary/1, &is_atom/1])
iex> Saul.validate(:foo, validator)
{:ok, :foo}
"""
@spec one_of(nonempty_list(validator(term))) :: validator(term)
def one_of([_ | _] = validators) do
%Saul.Validator.OneOf{validators: validators}
end
@doc """
Returns a validator that matches an enumerable where all elements match
`validator`.
The return value of this validator is a value constructed by collecting the
values in the given enumerable transformed according to `validator` into the
collectable specified by the `:into` option. This validator can be considered
analogous to the `for` special form (with the `:into` option as well), but
with error handling. If any of the elements in the given enumerable fails
`validator`, this validator fails.
## Options
* `:into` - (`t:Collectable.t/0`) the collectable where the transformed values
should end up in. Defaults to `[]`.
## Examples
iex> validator = Saul.enum_of(&{:ok, {inspect(&1), &1}}, into: %{})
iex> Saul.validate(%{foo: :bar}, validator)
{:ok, %{"{:foo, :bar}" => {:foo, :bar}}}
iex> Saul.validate([1, 2, 3], validator)
{:ok, %{"1" => 1, "2" => 2, "3" => 3}}
"""
@spec enum_of(Saul.validator(term), Keyword.t) :: Saul.validator(Collectable.t)
def enum_of(validator, options \\ []) when is_list(options) do
Saul.Enum.enum_of(validator, options)
end
@doc """
Returns a validator that validates a map with the shape specified by
`validators_map`.
`validators_map` must be a map with values as keys and two-element tuples
`{required_or_optional, validator}` as values. The input map will be validated
like this:
* each key is checked against the validator at the corresponding key in
`validators_map`
* `{:required, validator}` validators mean that their corresponding key is
required in the map; if it's not present in the input map, this
validator fails
* `{:optional, validator}` validators mean that their corresponding key
can be not present in the map, and it's only validated with `validator`
in case it's present
The map returned by this validator has unchanged keys and values that are the
result of the validator for each key.
## Options
* `:strict` (boolean) - if this option is `true`, then this validator fails
if the input map has keys that are not in `validators_map`. Defaults to
`false`.
## Examples
iex> validator = Saul.map([strict: false], %{
...> to_string: {:required, &{:ok, to_string(&1)}},
...> is_atom: {:optional, &is_atom/1},
...> })
iex> Saul.validate(%{to_string: :foo, is_atom: :bar}, validator)
{:ok, %{to_string: "foo", is_atom: :bar}}
iex> Saul.validate(%{to_string: :foo}, validator)
{:ok, %{to_string: "foo"}}
"""
@spec map(Keyword.t , %{optional(term) => {:required | :optional, validator(term)}}) ::
validator(map)
def map(options \\ [], validators_map) when is_list(options) and is_map(validators_map) do
Saul.Validator.Map.new(validators_map, options)
end
@doc """
Returns a validator that validates a tuples with elements that match the
validator at their corresponding position in `validators`.
The return value of this validator is a tuple with the same number of elements
as `validators` (and the input tuple) where elements are the result of the
validator in their corresponding position in `validators`.
## Examples
iex> atom_to_string = Saul.transform(&Atom.to_string/1)
iex> Saul.validate({:foo, :bar}, Saul.tuple({atom_to_string, atom_to_string}))
{:ok, {"foo", "bar"}}
"""
@spec tuple(tuple) :: validator(tuple)
def tuple(validators) when is_tuple(validators) do
Saul.Tuple.tuple(validators)
end
@doc """
Returns a validator that validates a map with keys that match `key_validator`
and values that match `value_validator`.
The return value of this validator is a map where keys are the result of
`key_validator` for each key and values are the result of `value_validator`
for the corresponding key. If any key or value fail, this validator fails.
Note that if `key_validator` ends up transforming two keys into the same term,
then they will collapse under just one key-value pair in the transformed map
and there is no guarantee on which value will prevail.
## Examples
iex> integer_to_string = Saul.all_of([&is_integer/1, &{:ok, Integer.to_string(&1)}])
iex> validator = Saul.map_of(integer_to_string, &is_atom/1)
iex> Saul.validate(%{1 => :ok, 2 => :not_so_ok}, validator)
{:ok, %{"1" => :ok, "2" => :not_so_ok}}
"""
@spec map_of(validator(key), validator(value)) :: validator(%{optional(key) => value})
when key: any, value: any
def map_of(key_validator, map_validator) do
Saul.Map.map_of(key_validator, map_validator)
end
@doc """
Returns a validator that validates a list where all elements match `validator`.
The return value of this validator is a list where each element is the return
value of `validator` for the corresponding element in the input
list. Basically this is analogous to `Enum.map/2` but with error handling. If
any of the elements in the list fail `validator`, this validator fails.
## Examples
iex> integer_to_string = Saul.all_of([&is_integer/1, &{:ok, Integer.to_string(&1)}])
iex> Saul.validate([1, 2, 3], Saul.list_of(integer_to_string))
{:ok, ["1", "2", "3"]}
"""
@spec list_of(validator(value)) :: validator([value]) when value: any
def list_of(validator) do
[&is_list/1, enum_of(validator, into: [])]
|> all_of()
|> named_validator("list_of")
end
@doc """
Returns a validator that checks if the input term is a member of `enumerable`.
The return value of this validator is the input term, unmodified.
`Enum.member?/2` is used to check if the input term is a member of
`enumerable`.
## Examples
iex> Saul.validate(:bar, Saul.member([:foo, :bar, :baz]))
{:ok, :bar}
iex> Saul.validate(50, Saul.member(1..100))
{:ok, 50}
"""
@spec member(Enumerable.t) :: validator(term)
def member(enumerable) do
%Saul.Validator.Member{enumerable: enumerable}
end
end