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