Current section

Files

Jump to
keyword_validator lib keyword_validator.ex
Raw

lib/keyword_validator.ex

defmodule KeywordValidator do
@moduledoc """
Functions for validating keyword lists.
The main function in this module is `validate/2`, which allows developers to
validate a keyword list against a given schema.
A schema is simply a map that matches the keys for the keyword list. The values
in the schema represent the options available during validation.
iex> KeywordValidator.validate([foo: :foo], %{foo: [type: :atom, required: true]})
{:ok, [foo: :foo]}
iex> KeywordValidator.validate([foo: :foo], %{foo: [inclusion: [:one, :two]]})
{:error, [foo: ["must be one of: [:one, :two]"]]}
"""
@type key_type ::
:any
| :atom
| :bistring
| :boolean
| :float
| :function
| {:function, non_neg_integer()}
| :integer
| :list
| :map
| :number
| :pid
| :port
| :struct
| {:struct, module()}
| :tuple
| {:tuple, non_neg_integer()}
@type key_opt ::
{:default, any()}
| {:required, boolean()}
| {:type, key_type() | [key_type()]}
| {:format, Regex.t()}
| {:custom, (atom(), any() -> [] | [binary()])}
| {:inclusion, list()}
| {:exclusion, list()}
@type key_opts :: [key_opt()]
@type schema :: %{atom() => key_opts()}
@type invalid :: [{atom(), [String.t()]}]
@default_opts [
default: nil,
required: false,
type: :any,
format: nil,
custom: [],
inclusion: [],
exclusion: []
]
@doc """
Validates a keyword list using the provided schema.
A schema is a simple map, with each key representing a key in your keyword list.
The values in the map represent the options available for validation.
If the validation passes, we are returned a two-item tuple of `{:ok, keyword}`.
Otherwise, returns `{:error, invalid}` - where `invalid` is a keyword list of errors.
## Schema Options
* `:required` - boolean representing whether the key is required or not, defaults to `false`
* `:default` - the default value for the key if not provided one, defaults to `nil`
* `:type` - the type associated with the key value. must be one of `t:key_type/0`
* `:format` - a regex used to validate string format
* `:inclusion` - a list of items that the value must be a included in
* `:exclusion` - a list of items that the value must not be included in
* `:custom` - a list of two-arity functions that serve as custom validators.
the function will be given the key and value as arguments, and must return
a list of string errors (or an empty list if no errors are present)
## Example
iex> KeywordValidator.validate([foo: :foo], %{foo: [type: :atom, required: true]})
{:ok, [foo: :foo]}
iex> KeywordValidator.validate([foo: :foo], %{bar: [type: :any]})
{:error, [foo: ["is not a valid key"]]}
iex> KeywordValidator.validate([foo: :foo], %{foo: [inclusion: [:one, :two]]})
{:error, [foo: ["must be one of: [:one, :two]"]]}
iex> KeywordValidator.validate([foo: "foo"], %{foo: [format: ~r/foo/]})
{:ok, [foo: "foo"]}
iex> KeywordValidator.validate([foo: "foo"], %{foo: [custom: [fn key, val -> ["some error"] end]]})
{:error, [foo: ["some error"]]}
"""
@spec validate(keyword(), schema()) :: {:ok, keyword()} | {:error, invalid()}
def validate(keyword, schema) when is_list(keyword) and is_map(schema) do
{[], keyword, []}
|> validate_extra_keys(schema)
|> validate_keys(schema)
|> to_tagged_tuple()
end
@doc """
The same as `validate/2` but raises an `ArgumentError` exception if invalid.
## Example
iex> KeywordValidator.validate!([foo: :foo], %{foo: [type: :atom, required: true]})
[foo: :foo]
iex> KeywordValidator.validate!([foo: :foo], %{foo: [inclusion: [:one, :two]]})
** (ArgumentError) Invalid keyword given.
Keyword:
[foo: :foo]
Invalid:
foo: ["must be one of: [:one, :two]"]
"""
@spec validate!(keyword(), schema()) :: any()
def validate!(keyword, schema) do
case validate(keyword, schema) do
{:ok, parsed} ->
parsed
{:error, invalid} ->
raise ArgumentError, """
Invalid keyword given.
Keyword:
#{inspect(keyword, pretty: true)}
Invalid:
#{format_invalid(invalid)}
"""
end
end
defp validate_extra_keys({parsed, keyword, invalid}, schema) do
invalid =
Enum.reduce(keyword, invalid, fn {key, _val}, invalid ->
if Map.has_key?(schema, key) do
invalid
else
put_error(invalid, key, "is not a valid key")
end
end)
{parsed, keyword, invalid}
end
defp put_error(invalid, key, msg) when is_binary(msg) do
Keyword.update(invalid, key, [msg], fn errors ->
[msg | errors]
end)
end
defp put_error(invalid, key, msgs) when is_list(msgs) do
Enum.reduce(msgs, invalid, &put_error(&2, key, &1))
end
defp validate_keys(result, schema) do
Enum.reduce(schema, result, &maybe_validate_key(&1, &2))
end
defp maybe_validate_key({key, opts}, {parsed, keyword, invalid}) do
opts = @default_opts |> Keyword.merge(opts) |> Enum.into(%{})
if Keyword.has_key?(keyword, key) do
validate_key({key, opts}, {parsed, keyword, invalid})
else
if opts.required do
validate_key({key, opts}, {parsed, keyword, invalid})
else
{parsed, keyword, invalid}
end
end
end
defp validate_key({key, opts}, {parsed, keyword, invalid}) do
val = Keyword.get(keyword, key, opts.default)
{key, opts, val, []}
|> validate_required()
|> validate_type()
|> validate_format()
|> validate_inclusion()
|> validate_exclusion()
|> validate_custom()
|> elem(3)
|> case do
[] -> {Keyword.put(parsed, key, val), keyword, invalid}
errors -> {parsed, keyword, put_error(invalid, key, errors)}
end
end
defp validate_required({key, %{required: true} = opts, nil, errors}) do
{key, opts, nil, ["is a required key" | errors]}
end
defp validate_required(validation) do
validation
end
defp validate_type({key, opts, val, errors}) do
case validate_type(opts.type, val) do
true -> {key, opts, val, errors}
msg -> {key, opts, val, [msg | errors]}
end
end
defp validate_type(:any, _val), do: true
defp validate_type(:atom, val) when is_atom(val) and not is_nil(val), do: true
defp validate_type(:atom, _val), do: "must be an atom"
defp validate_type(:binary, val) when is_binary(val), do: true
defp validate_type(:binary, _val), do: "must be a binary"
defp validate_type(:bitstring, val) when is_bitstring(val), do: true
defp validate_type(:bitstring, _val), do: "must be a bitstring"
defp validate_type(:boolean, val) when is_boolean(val), do: true
defp validate_type(:boolean, _val), do: "must be a boolean"
defp validate_type(:float, val) when is_float(val), do: true
defp validate_type(:float, _val), do: "must be a float"
defp validate_type(:function, val) when is_function(val), do: true
defp validate_type(:function, _val), do: "must be a function"
defp validate_type({:function, arity}, val) when is_function(val, arity), do: true
defp validate_type({:function, arity}, _val), do: "must be a function of arity #{arity}"
defp validate_type(:integer, val) when is_integer(val), do: true
defp validate_type(:integer, _val), do: "must be an integer"
defp validate_type(:list, val) when is_list(val), do: true
defp validate_type(:list, _val), do: "must be a list"
defp validate_type(:map, val) when is_map(val), do: true
defp validate_type(:map, _val), do: "must be a map"
defp validate_type(:number, val) when is_number(val), do: true
defp validate_type(:number, _val), do: "must be a number"
defp validate_type(:pid, val) when is_pid(val), do: true
defp validate_type(:pid, _val), do: "must be a PID"
defp validate_type(:port, val) when is_port(val), do: true
defp validate_type(:port, _val), do: "must be a port"
defp validate_type(:struct, %{__struct__: _}), do: true
defp validate_type(:struct, _val), do: "must be a struct"
defp validate_type({:struct, type1}, %{__struct__: type2}) when type1 == type2, do: true
defp validate_type({:struct, type}, _val), do: "must be a struct of type #{inspect(type)}"
defp validate_type(:tuple, val) when is_tuple(val), do: true
defp validate_type(:tuple, _val), do: "must be a tuple"
defp validate_type({:tuple, size}, val) when is_tuple(val) and tuple_size(val) == size, do: true
defp validate_type({:tuple, size}, _val), do: "must be a tuple of size #{size}"
defp validate_type(types, val) when is_list(types) do
if Enum.any?(types, fn type -> validate_type(type, val) == true end) do
true
else
"must be one of the following: #{inspect(types)}"
end
end
defp validate_format({key, %{format: %Regex{} = format} = opts, val, errors}) do
if val =~ format do
{key, opts, val, errors}
else
{key, opts, val, ["has invalid format" | errors]}
end
end
defp validate_format(validation) do
validation
end
defp validate_inclusion({_, %{inclusion: []}, _, _} = validation) do
validation
end
defp validate_inclusion({key, %{inclusion: inclusion} = opts, val, errors}) do
if Enum.member?(inclusion, val) do
{key, opts, val, errors}
else
{key, opts, val, ["must be one of: #{inspect(inclusion)}" | errors]}
end
end
defp validate_exclusion({_, %{exclusion: []}, _, _} = validation) do
validation
end
defp validate_exclusion({key, %{exclusion: exclusion} = opts, val, errors}) do
if Enum.member?(exclusion, val) do
{key, opts, val, ["must not be one of: #{inspect(exclusion)}" | errors]}
else
{key, opts, val, errors}
end
end
defp validate_custom({_, %{custom: []}, _, _} = validation) do
validation
end
defp validate_custom({key, %{custom: custom} = opts, val, errors}) do
errors =
Enum.reduce(custom, errors, fn validator, errors ->
validator.(key, val) ++ errors
end)
{key, opts, val, errors}
end
defp to_tagged_tuple({parsed, _, []}), do: {:ok, parsed}
defp to_tagged_tuple({_, _, invalid}), do: {:error, invalid}
defp format_invalid(invalid) do
invalid
|> Enum.reduce("", fn {key, errors}, final ->
final <> " #{key}: #{inspect(errors, pretty: true)}\n"
end)
|> String.trim_trailing("\n")
end
end