Current section
Files
Jump to
Current section
Files
lib/ashton.ex
defmodule Ashton do
@moduledoc """
Documentation for Ashton.
"""
@type schema() :: Ashton.Schema.t()
@type error :: {atom, String.t()}
@type validation_result :: {:ok, Keyword.t()} | {:error, [error]}
@doc "See `Ashton.Schema.new/1`"
defdelegate schema(opts), to: Ashton.Schema, as: :new
defdelegate schema(), to: Ashton.Schema, as: :new
@doc "See `Ashton.Schema.merge/2`"
defdelegate merge(left, right), to: Ashton.Schema
defdelegate merge(left, right, opts), to: Ashton.Schema
@doc "See `Ashton.Doc.document/2`"
defdelegate document(schema, opts), to: Ashton.Doc
defdelegate document(schema), to: Ashton.Doc
@doc """
Validates opts according to a schema or the constructor for a schema. Raises on invalid opts.
iex> Ashton.validate!([reticulate_splines?: true], opts: [:reticulate_splines?])
[reticulate_splines?: true]
iex> Ashton.validate!([reticulate_splines?: true], opts: [:load_textures?], extra_keys?: true)
[reticulate_splines?: true]
iex> schema = Ashton.schema(opts: [:reticulate_splines?], required: [:reticulate_splines?], extra_keys?: true)
...> Ashton.validate!([reticulate_splines?: true, hack_interwebs?: true], schema)
[reticulate_splines?: true, hack_interwebs?: true]
iex> Ashton.validate!([], opts: [:reticulate_splines?], required: [:reticulate_splines?])
** (ArgumentError) Opt Validation Error: reticulate_splines? - is required
"""
@spec validate!(opts :: Keyword.t(), schema :: schema()) :: Keyword.t() | no_return
def validate!(opts, schema = %Ashton.Schema{}) do
case validate(opts, schema) do
{:ok, opts} ->
opts
{:error, errors} ->
message = message(errors)
raise ArgumentError, message
end
end
def validate!(opts, schema_config) do
validate!(opts, schema(schema_config))
end
@spec validate(opts :: Keyword.t(), schema :: Ashton.Schema.t()) ::
{:ok, Keyword.t()} | {:error, [error]}
def validate(opts, schema) when is_map(opts) do
validate(Enum.into(opts, []), schema)
end
def validate(opts, schema) when is_list(opts) do
with_defaults =
Enum.reduce(schema.defaults, opts, fn {default, value}, opts ->
Keyword.put_new(opts, default, value)
end)
{:ok, with_defaults}
|> validate_required(with_defaults, schema)
|> validate_types(with_defaults, schema)
|> validate_extra_keys(with_defaults, schema)
|> validate_custom(schema)
|> validate_constraints(schema)
end
def validate(_opts, _schema) do
{:error, [{:opts, "opts must be a keyword list or a map."}]}
end
defp validate_constraints({:ok, opts}, %{constraints: []}) do
{:ok, opts}
end
defp validate_constraints({:ok, opts}, schema = %{constraints: [{key, {func, label}} | rest]}) do
if func.(opts[key]) do
validate_constraints({:ok, opts}, %{schema | constraints: rest})
else
add_errors({:ok, opts}, {key, "failed constraint: #{label}"})
end
end
defp validate_constraints({:ok, opts}, schema = %{constraints: [{_key, []} | rest]}) do
validate_constraints({:ok, opts}, %{schema | constraints: rest})
end
defp validate_constraints(
{:ok, opts},
schema = %{
constraints: [{key, [{func, label} | rest_constraints]} | rest]
}
) do
if func.(opts[key]) do
validate_constraints(
{:ok, opts},
%{schema | constraints: [{key, rest_constraints} | rest]}
)
else
add_errors({:ok, opts}, {key, "failed constraint: #{label}"})
end
end
defp validate_constraints(validation_result, _schema) do
validation_result
end
defp validate_custom(
validation_result = {:ok, opts},
schema = %{custom: [{field, custom} | rest]}
) do
result =
case custom.(opts[field], field, opts, schema) do
true ->
validation_result
false ->
add_errors(validation_result, {field, "failed a custom validation"})
:ok ->
validation_result
{:ok, updated_opts} ->
{:ok, updated_opts}
{:error, error_or_errors} ->
add_errors(validation_result, error_or_errors)
[] ->
validation_result
errors when is_list(errors) ->
add_errors(validation_result, errors)
end
validate_custom(result, %{schema | custom: rest})
end
defp validate_custom(validation_result, _schema), do: validation_result
defp validate_types(validation_result, opts, %{types: types}) do
Enum.reduce(types, validation_result, fn {field, type}, result ->
cond do
!Keyword.has_key?(opts, field) ->
result
match?(%Ashton.Schema{}, type) ||
match?({nested_type, %Ashton.Schema{}} when nested_type in [:keyword, :list], type) ->
validate_nested_schema(result, type, opts, field)
Ashton.Type.matches_type?(type, opts[field]) ->
result
true ->
{_, message} = Ashton.Type.format_type(to_string(field), type)
add_errors(result, {to_string(field), "must be " <> message})
end
end)
end
defp validate_nested_schema(result, type, opts, field) do
case do_nested_schema_validation(type, opts[field]) do
{:ok, value} ->
update_result_key(result, field, value)
{:error, message} ->
add_errors(result, {field, message})
end
end
defp do_nested_schema_validation({:list, _schema}, value)
when not is_list(value) do
{:error, "expected a list of keywords conforming to a subschema"}
end
defp do_nested_schema_validation({:list, schema}, value) do
nested_opts_result =
value
|> Enum.with_index()
|> Enum.reduce({[], []}, fn {keyword, index}, {opts_acc, errors_acc} ->
case validate(keyword, schema) do
{:ok, new_opts} ->
{[new_opts | opts_acc], errors_acc}
{:error, errors} ->
message = nested_error_message(index, errors)
{opts_acc, [message | errors_acc]}
end
end)
case nested_opts_result do
{opts_acc, []} ->
{:ok, Enum.reverse(opts_acc)}
{_, errors} ->
message = Enum.join(errors, ", ")
{:error, message}
end
end
defp do_nested_schema_validation({:keyword, schema}, value) do
nested_opts_result =
value
|> Enum.reduce({[], []}, fn {key, keyword}, {opts_acc, errors_acc} ->
case validate(keyword, schema) do
{:ok, new_opts} ->
{[{key, new_opts} | opts_acc], errors_acc}
{:error, errors} ->
message = nested_error_message(key, errors)
{opts_acc, [message | errors_acc]}
end
end)
case nested_opts_result do
{opts_acc, []} ->
{:ok, Enum.reverse(opts_acc)}
{_, errors} ->
message = Enum.join(errors, ", ")
{:error, message}
end
end
defp do_nested_schema_validation(schema = %Ashton.Schema{}, value) do
case validate(value, schema) do
{:ok, new_opts} ->
{:ok, new_opts}
{:error, errors} ->
message =
Enum.map_join(errors, ", ", fn {nested_field, message} ->
"nested field #{nested_field} #{message}"
end)
{:error, message}
end
end
defp nested_error_message(nesting, errors) do
Enum.map_join(errors, ", ", fn {nested_field, message} ->
"nested field [#{nesting}][#{nested_field}] #{message}"
end)
end
defp validate_required(validation_result, opts, %{required: required}) do
Enum.reduce(required, validation_result, fn key, result ->
if is_nil(opts[key]) do
add_errors(result, {key, "is required"})
else
result
end
end)
end
defp validate_extra_keys(validation_result, _opts, %{extra_keys?: true}),
do: validation_result
defp validate_extra_keys(validation_result, opts, %{opts: keys}) do
extra_keys =
opts
|> Keyword.keys()
|> Kernel.--(keys)
Enum.reduce(
extra_keys,
validation_result,
&add_errors(&2, {&1, "is not allowed (no extra keys)"})
)
end
defp update_result_key({:ok, opts}, field, value) do
{:ok, Keyword.put(opts, field, value)}
end
defp update_result_key(other, _, _) do
other
end
defp add_errors({:ok, _opts}, error_or_errors) do
add_errors({:error, []}, error_or_errors)
end
defp add_errors({:error, existing_errors}, error_or_errors) do
errors = List.wrap(error_or_errors)
{:error, errors ++ existing_errors}
end
defp message(errors) do
short_messages =
errors
|> Enum.map(&short_message/1)
|> Enum.join(", ")
"Opt Validation Error: " <> "#{short_messages}"
end
defp short_message({path, message}) when is_list(path) do
path =
path
|> Enum.with_index()
|> Enum.map(fn {elem, i} ->
if i == 0 do
elem
else
"[:#{elem}]"
end
end)
|> Enum.join()
"#{path} - #{message}"
end
defp short_message({field, message}) do
"#{field} - #{message}"
end
end