defmodule Justify do
@moduledoc """
Justify makes it easy to validate unstructured data.
Inspired heavily by [Ecto.Changeset][1], Justify allows you to pipe a plain map
into a series of validation functions using a simple and familiar API. No
schemas or casting required.
[1]: https://hexdocs.pm/ecto/Ecto.Changeset.html
### Example
```elixir
dataset =
%{email: "madebyanthony"}
|> Justify.validate_required(:email)
|> Justify.validate_format(:email, ~r/\S+@\S+/)
dataset.errors #=> [email: {"has invalid format", validation: :format}]
dataset.valid? #=> false
```
Each validation function will return a `Justify.Dataset` struct which can be
passed into the next function. If a validation error is encountered the dataset
will be marked as invalid and an error will be added to the struct.
## Custom Validations
You can provide your own custom validations using the `Justify.add_error/4`
function.
### Example
```elixir
defmodule MyValidator do
def validate_color(data, field, color) do
dataset = Justify.Dataset.new(data)
value = Map.get(dataset.data, :field)
if value == color do
dataset
else
Justify.add_error(dataset, field, "wrong color", validation: :color)
end
end
end
```
Your custom validation can be used as part of a validation pipeline.
### Example
```elixir
dataset =
%{color: "brown"}
|> Justify.validation_required(:color)
|> MyValidator.validate_color(:color, "green")
dataset.errors #=> [color: {"wrong color", validation: :color}]
dataset.valid? #=> false
```
## Supported Validations
* [`validate_acceptance/3`](https://hexdocs.pm/justify/Justify.html#validate_acceptance/3)
* [`validate_confirmation/3`](https://hexdocs.pm/justify/Justify.html#validate_confirmation/3)
* [`validate_embed/3`](https://hexdocs.pm/justify/Justify.html#validate_embed/3)
* [`validate_exclusion/4`](https://hexdocs.pm/justify/Justify.html#validate_exclusion/4)
* [`validate_format/4`](https://hexdocs.pm/justify/Justify.html#validate_format/4)
* [`validate_inclusion/4`](https://hexdocs.pm/justify/Justify.html#validate_inclusion/4)
* [`validate_length/3`](https://hexdocs.pm/justify/Justify.html#validate_length/3)
* [`validate_required/3`](https://hexdocs.pm/justify/Justify.html#validate_required/3)
* [`validate_type/4`](https://hexdocs.pm/justify/Justify.html#validate_type/4)
"""
@type type_t ::
:boolean
| :float
| :integer
| :non_neg_integer
| :pos_integer
| :string
@doc """
Validates the given field has a value of `true`.
## Options
* `:message` - error message, defaults to "must be accepted"
"""
@spec validate_acceptance(map, atom, Keyword.t()) :: Justify.Dataset.t()
defdelegate validate_acceptance(dataset, field, opts \\ []),
to: Justify.Validators.Acceptance,
as: :call
@doc """
Validates the value of a given field matches it's confirmation field.
By default, the field will be checked against a field with the same name
but appended with `_confirmation`. It’s possible to provide a custom field by
providing a value to the `:confirmation_field` option.
Note that if the confirmation field is `nil` or missing, by default, an error
will not be added. You can specify that the confirmation field is required in
the options (see below).
## Options
* `:confirmation_field` - name of the field to validate against
* `:message` - error message, defaults to "does not match"
* `:required?` - whether the confirmation field must contain a value
"""
@spec validate_confirmation(map, atom, Keyword.t()) :: Justify.Dataset.t()
defdelegate validate_confirmation(dataset, field, opts \\ []),
to: Justify.Validators.Confirmation,
as: :call
@doc """
Applies a validator function to a field containing an embedded value.
An embedded value can be either a map or a list of maps.
## Example
validator = fn(metadata) -> Justify.validate_required(metadata, :key) end
data = %{metadata: [%{value: "a value"}]}
validate_embed(data, :metadata, validator)
#> %Justify.Dataset{errors: [metadata: [[key: {"can't be blank", validation: :required}]]], valid?: false}
"""
@spec validate_embed(map, atom, fun) :: Justify.Dataset.t()
defdelegate validate_embed(dataset, field, validator),
to: Justify.Validators.Embed,
as: :call
@doc """
Validates the value for the given field is not contained within the provided
enumerable.
## Options
* `:message` - error message, defaults to "is reserved"
"""
@spec validate_exclusion(map, atom, Enum.t(), Keyword.t()) :: Justify.Dataset.t()
defdelegate validate_exclusion(dataset, field, enum, opts \\ []),
to: Justify.Validators.Exclusion,
as: :call
@doc """
Validates the value of the given field matches the provided format.
## Options
* `:message` - error message, defaults to "has invalid format"
"""
@spec validate_format(map, atom, Regex.t(), Keyword.t()) :: Justify.Dataset.t()
defdelegate validate_format(dataset, field, format, opts \\ []),
to: Justify.Validators.Format,
as: :call
@doc """
Validates the value for the given field is contained within the provided
enumerable.
## Options
* `:message` - error message, defaults to "is invalid"
"""
@spec validate_inclusion(map, atom, Enum.t(), Keyword.t()) :: Justify.Dataset.t()
defdelegate validate_inclusion(dataset, field, enum, opts \\ []),
to: Justify.Validators.Inclusion,
as: :call
@doc """
Validates the length of a string or list.
## Options
* `:count` - how to calculate the length of a string. Must be one of
`:codepoints`, `:graphemes` or `:bytes`. Defaults to
`:graphemes`.
* `:is` - the exact length match
* `:min` - match a length greater than or equal to
* `:max` - match a length less than or equal to
* `:message` - error message, defaults to one of the following variants:
* for strings
* “should be %{count} character(s)”
* “should be at least %{count} character(s)”
* “should be at most %{count} character(s)”
* for binary
* “should be %{count} byte(s)”
* “should be at least %{count} byte(s)”
* “should be at most %{count} byte(s)”
* for lists
* “should have %{count} item(s)”
* “should have at least %{count} item(s)”
* “should have at most %{count} item(s)”
"""
@spec validate_length(map, atom, Keyword.t()) :: Justify.Dataset.t()
defdelegate validate_length(dataset, field, opts),
to: Justify.Validators.Length,
as: :call
@doc """
Validates that one or more fields has a value.
## Options
* `:message` - error message, defaults to "must be accepted"
* `:trim?` - remove whitespace before validating, defaults to `true`
"""
@spec validate_required(map, atom | [atom], Keyword.t()) :: Justify.Dataset.t()
defdelegate validate_required(dataset, fields, opts \\ []),
to: Justify.Validators.Required,
as: :call
@doc """
Validates that the value of a field is a specific type.
Supported types:
* `:boolean`
* `:float`
* `:integer`
* `:non_neg_integer`
* `:pos_integer`
* `:string`
## Options
* `:message` - error message, defaults to "has invalid type"
"""
@spec validate_type(map, atom, type_t, Keyword.t()) :: Justify.Dataset.t()
defdelegate validate_type(dataset, field, type, opts \\ []),
to: Justify.Validators.Type,
as: :call
@doc """
Adds an error to the dataset.
An optional keyword list can be used to provide additional contextual
information about the error.
"""
@spec add_error(Justify.Dataset.t(), atom, String.t(), Keyword.t()) :: Justify.Dataset.t()
def add_error(dataset, field, message, keys \\ []) do
put_error(dataset, field, { message, keys })
end
@doc false
def put_error(dataset, field, error) do
errors =
dataset
|> Map.get(:errors)
|> Enum.concat([{ field, error }])
%{ dataset | errors: errors, valid?: false }
end
end