Packages

Validate input data against a schema.

Current section

Files

Jump to
dry_validation lib dry_validation.ex
Raw

lib/dry_validation.ex

defmodule DryValidation do
@moduledoc """
Used to create a schema to validate input data against.
## Example
```
iex> alias DryValidation.Types
...>
iex> schema = DryValidation.schema do
...> required :name, Types.String
...> optional :age, Types.Integer
...> end
...>
iex> input_data = %{"name" => "John", "age" => "15"}
iex> {:ok, output_data} = DryValidation.Validator.validate(schema, input_data)
iex> assert output_data == %{"name" => "John", "age" => 15}
...>
iex> input_data = %{"name" => 15, "age" => "nonsense"}
iex> {:error, error} = DryValidation.Validator.validate(schema, input_data)
iex> assert error == %{
...> "name" => "15 is not a valid type; Expected type is DryValidation.Types.String",
...> "age" => ~s("nonsense" is not a valid type; Expected type is DryValidation.Types.Integer)
...> }
```
Complex schemas can be crafted using the methods - `required`, `optional`, `map` and `map_list`.
With the use the provided `DryValidation.Types`, requirements can be set and also cast values when possible.
## Available Types
Type | Description
------------- | -------------
`DryValidation.Types.String` | Expects a string type `"some text"`. Will try to cast the value into a string (`1001` = `"1001"`).
`DryValidation.Types.Bool` | Expects a boolean type `[true/false]`. Will cast the strings "true"/"false" to real booleans
`DryValidation.Types.Float` | Expects a float type `[15.51]`. Will try to cast a string to a float (`"15.5"` = `15.5`).
`DryValidation.Types.Integer` | Expects an integer type `[101]`. Will try to cast a string to an integer (`"100"` = `100`). It'll fail the cast if the string is a float.
`DryValidation.Types.Func` | Custom rules can be build using this, see the module docs. Example is the `DryValidation.Types.Integer.greater_than(5)` rule.
`DryValidation.Types.List` | Expects a list. Can have the list type set to one of the above, including a `Func`.
`DryValidation.Types.Any` | Accepts any value and will do no casting. Usually not used as the type can just be omitted when using `optional` and `required`
## Advanced example
```
schema = DryValidation.schema do
required :name, Types.String
required :age, Types.Integer.greater_than(18)
required :type, Types.Func.equal("users")
optional :pets, Types.Func.member_of(["dog", "cat", "bird"])
optional :favourite_numbers, Types.List.type(Types.Integer)
map_list :cars do
required :make, Types.String
required :cc, Types.Integer
end
map :house, optional: true do
required :address, Types.String
end
end
input_data = %{
"name" => "Jon Snow",
"age" => 42,
"type" => "users",
"pet" => "dog",
"favourite_numbers" => [],
"cars" => [
%{"make" => "AUDI", "cc" => 3000},
%{"make" => "BMW", "cc" => 2000},
],
"house" => %{
"address" => "Church Road"
}
}
{:ok, _output_data} = DryValidation.Validator.validate(schema, input_data)
```
"""
@doc """
Creates a validation schema.
"""
defmacro schema(do: block) do
quote do
import DryValidation
{:ok, var!(buffer, __MODULE__)} = start_buffer([])
unquote(block)
result = render(var!(buffer, __MODULE__))
:ok = stop_buffer(var!(buffer, __MODULE__))
result
end
end
@doc false
def start_buffer(state), do: Agent.start_link(fn -> state end)
@doc false
def stop_buffer(buff), do: Agent.stop(buff)
@doc false
def put_buffer(buff, content), do: Agent.update(buff, &[content | &1])
@doc false
def render(buff), do: Agent.get(buff, & &1) |> Enum.reverse() |> DryValidation.construct([])
@doc false
def last_start_block_id(buff) do
Agent.get(buff, & &1)
|> Enum.filter(fn x -> Map.get(x, :block) end)
|> Enum.map(fn x -> Map.get(x, :id) end)
|> Enum.max(&>=/2, fn -> 0 end)
end
@doc """
Defines a list of maps. Can be made optional.
```
schema = DryValidation.schema do
map_list :users, do
required(:name, Types.String)
end
end
input_data = %{"users" => [%{"name" => "John"}, %{"name" => "Bob"}]}
{:ok, output_data} = DryValidation.Validator.validate(schema, input_data)
```
"""
defmacro map_list(name, opts \\ [], do: inner) do
quote do
map(unquote(name), unquote(Keyword.put(opts, :rule, :map_list)), do: unquote(inner))
end
end
@doc """
Defines a map. Can be made optional.
```
schema = DryValidation.schema do
map :user, do
required(:name, Types.String)
end
map :car, optional: true do
required(:name, Types.String)
end
end
input_data = %{"user" => %{"name" => "John"}}
{:ok, output_data} = DryValidation.Validator.validate(schema, input_data)
```
"""
defmacro map(name, opts \\ [], do: inner) do
quote do
optional = unquote(Keyword.get(opts, :optional, false))
rule = unquote(Keyword.get(opts, :rule, :map))
new_id = last_start_block_id(var!(buffer, __MODULE__)) + 1
put_buffer(
var!(buffer, __MODULE__),
%{rule: rule, block: :start, name: unquote(name), optional: optional, id: new_id}
)
unquote(inner)
put_buffer(
var!(buffer, __MODULE__),
%{rule: rule, block: :end, name: unquote(name), optional: optional, id: new_id}
)
end
end
@doc """
Defines an optional attribute in the schema.
First argument is the name of the attribute, second argument is optional and defines the type.
```
schema = DryValidation.schema do
required(:name)
optional(:age)
end
input_data = %{"name" => "Jon"}
{:ok, output_data} = DryValidation.Validator.validate(schema, input_data)
output_data == %{"name" => "Jon"}
```
"""
defmacro optional(name, type \\ nil) do
quote do
tag(:optional, unquote(name), unquote(type))
end
end
@doc """
Defines a mandatory attribute in the schema.
First argument is the name of the attribute, second argument is optional and defines the type.
```
schema = DryValidation.schema do
required(:name, Types.String)
optional(:age)
end
input_data = %{"age" => 21}
{:error, errors} = DryValidation.Validator.validate(schema, input_data)
errors == %{"name" => "Is missing"}
```
"""
defmacro required(name, type \\ nil) do
quote do
tag(:required, unquote(name), unquote(type))
end
end
@doc false
defmacro tag(tag, name, type \\ nil) do
quote do
put_buffer(
var!(buffer, __MODULE__),
%{rule: unquote(tag), name: unquote(to_string(name)), type: unquote(type)}
)
%{name: unquote(name)}
end
end
@doc false
def construct([%{block: :end} | tail], result) do
construct(tail, result)
end
@doc false
def construct(
[%{name: name, block: :start, rule: rule, optional: optional, id: id} | tail],
result
) do
{to_end, rest} =
Enum.split_while(tail, fn el ->
!(Map.get(el, :block) == :end && Map.get(el, :id) == id)
end)
inner = construct(to_end, [])
result =
result ++
[%{name: to_string(name), inner: inner, rule: rule, optional: optional}]
construct(rest, result)
end
@doc false
def construct([head | tail], result) do
construct(tail, result ++ [head])
end
def construct([], result) do
result
end
end