Packages

Toolbox that help to do trivial things faster. I have more free time for life

Current section

Files

Jump to
magik lib contract.ex
Raw

lib/contract.ex

defmodule Magik.Contract do
@moduledoc """
validate contract schema
## Support validation
**Type**
Support built-in types;
- `boolean`
- `integer`,
- `float`
- `number` - string or integer
- `string`
- `tuple`
- `map`
- `array`
- `list`
- `atom`
- `function`
- `keyword`
- `struct`
- `array` of type
Example:
```elixir
Magik.Contract.validate(%{name: "Bluz"}, %{name: [type: :string]})
Magik.Contract.validate(%{id: 123}, %{name: [type: :integer]})
Magik.Contract.validate(%{id: 123}, %{name: [type: {:array, :integer}]})
Magik.Contract.validate(%{user: %User{}}, %{user: [type: User]})
Magik.Contract.validate(%{user: %User{}}, %{user: [type: {:array: User}]})
```
**Required**
```elixir
Magik.Contract.validate(%{name: "Bluz"}, %{name: [type: :string, required: true]})
```
**Allow nil**
```elixir
Magik.Contract.validate(
%{name: "Bluz", email: nil},
%{
name: [type: :string],
email: [type: string, allow_nil: false]
})
```
**Inclusion/Exclusion**
```elixir
Magik.Contract.validate(
%{status: "active"},
%{status: [type: :string, in: ~w(active in_active)]}
)
Magik.Contract.validate(
%{status: "active"},
%{status: [type: :string, not_in: ~w(banned locked)]}
)
```
**Format**
Validate string against regex pattern
```elixir
Magik.Contract.validate(
%{email: "Bluzblu@gm.com"},
%{name: [type: :string, format: ~r/.+?@.+\.com/]
})
```
**Number**
Validate number value
```elixir
Magik.Contract.validate(
%{age: 200},
%{age: [type: :integer, number[greater_than: 0, less_than: 100]]
})
```
Support conditions
- `equal_to`
- `greater_than_or_equal_to` | `min`
- `greater_than`
- `less_than`
- `less_than_or_equal_to` | `max`
**Length**
Check length of `list`, `map`, `string`, `keyword`, `tuple`
Supported condtions are the same with **Number** check
```elixir
Magik.Contract.validate(
%{title: "Hello world"},
%{age: [type: :string, length: [min: 10, max: 100]]
})
```
**Custom validation function**
Invoke given function to validate value.
The function signature must be
```
func(field_name ::(String.t() | atom()), value :: any(), all_params :: map()) :: :ok | {:error, message}
```
```elixir
Magik.Contract.validate(
%{email: "blue@hmail.com"},
%{email: [type: :string, func: &validate_email/3]})
def validate_email(_name, email, _params) do
if Regex.match?(~r/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/, email) do
:ok
else
{:error, "not a valid email"}
end
end
```
**Nested map**
Nested map declaration is the same.
```elixir
data = %{name: "Doe John", address: %{city: "HCM", street: "NVL"} }
schema = %{
name: [type: :string],
address: [type: %{
city: [type: :string],
street: [type: :string]
}]
}
Magik.Contract.validate(data, schema)
```
**Nested list**
```elixir
data = %{name: "Doe John", address: [%{city: "HCM", street: "NVL"}] }
address = %{ city: [type: :string], street: [type: :string] }
schema = %{
name: [type: :string],
address: [type: {:array, address}]
}
Magik.Contract.validate(data, schema)
```
"""
alias Magik.Validator
def validate(data, schema) do
validation_result =
schema
|> Enum.map(fn {field_name, validations} ->
validations =
if Keyword.has_key?(validations, :allow_nil) do
validations
else
[{:allow_nil, false} | validations]
end
validations
|> Enum.map(fn validation ->
value = Map.get(data, field_name, :__missing)
do_validate(value, validation, field_name: field_name, data: data)
end)
|> collect_result()
|> case do
:ok -> :ok
{_, errors} -> {:error, {field_name, errors}}
end
end)
|> collect_result()
case validation_result do
:ok -> {:ok, data}
{:error, errors} -> {:error, Map.new(errors)}
end
end
defp do_validate(:__missing, {:required, true}, _opts) do
{:error, "is required"}
end
defp do_validate(_, {:required, _}, _), do: :ok
defp do_validate(:__missing, _, _), do: :ok
defp do_validate(value, {:allow_nil, allow_nil}, _) when is_boolean(allow_nil) do
if not is_nil(value) or allow_nil do
:ok
else
{:error, "cannot be nil"}
end
end
defp do_validate(nil, _, _), do: :ok
# validate nested type
defp do_validate(%{} = value, {:type, type}, _) when is_map(type) do
case validate(value, type) do
{:ok, _} -> :ok
error -> error
end
end
defp do_validate(_value, {:type, type}, _) when is_map(type) do
{:error, "is not a nested map"}
end
defp do_validate(value, {:type, {:array, type}}, _) when is_list(value) do
results =
value
|> Enum.map(fn item ->
do_validate(item, {:type, type}, value)
end)
if Enum.all?(results, &(&1 == :ok)) do
:ok
else
Enum.find(results, fn {rs, _} -> rs == :error end)
end
end
defp do_validate(value, {:type, type}, _) do
Validator.validate_type(value, type)
end
defp do_validate(value, {:in, enum}, _) do
if Enum.member?(enum, value) do
:ok
else
{:error, "not be in the inclusion list"}
end
end
defp do_validate(value, {:not_in, enum}, _) do
if Enum.member?(enum, value) do
{:error, "must not be in the exclusion list"}
else
:ok
end
end
defp do_validate(value, {:format, format}, _) do
Validator.validate_format(value, format)
end
defp do_validate(value, {:number, checks}, _) when is_number(value) do
Validator.validate_number(value, checks)
end
defp do_validate(_value, {:number, _checks}, _) do
{:error, "is not a number"}
end
defp do_validate(value, {:length, checks}, _) do
Validator.validate_length(value, checks)
end
defp do_validate(value, {:func, func}, opts) when is_function(func, 3) do
func.(opts[:field_name], value, opts[:data])
end
defp collect_result(results) do
summary =
Enum.reduce(results, :ok, fn
:ok, acc -> acc
{:error, msg}, :ok -> {:error, [msg]}
{:error, msg}, {:error, acc_msg} -> {:error, [msg | acc_msg]}
end)
case summary do
:ok ->
:ok
{:error, errors} ->
errors =
errors
|> Enum.map(fn item ->
if is_list(item) do
item
else
[item]
end
end)
|> Enum.concat()
{:error, errors}
end
end
end