defmodule Exop.Validation do
@moduledoc """
Provides high-level functions for a contract validation.
The main function is valid?/2
Mostly invokes Exop.ValidationChecks module functions.
"""
import Exop.ValidationChecks
alias Exop.Utils
defmodule ValidationError do
@moduledoc """
An operation's contract validation failure error.
"""
defexception message: "Contract validation failed"
end
@type validation_error :: {:error, {:validation, map()}}
@spec function_present?(Elixir.Exop.Validation | Elixir.Exop.ValidationChecks, atom()) ::
boolean()
defp function_present?(module, function_name) do
:functions |> module.__info__() |> Keyword.has_key?(function_name)
end
@doc """
Validate received params over a contract.
## Examples
iex> Exop.Validation.valid?([%{name: :param, opts: [required: true]}], [param: "hello"])
:ok
"""
@spec valid?(list(map()), Keyword.t() | map()) :: :ok | validation_error
def valid?(contract, received_params) do
validation_results = validate(contract, received_params, [])
if Enum.empty?(validation_results) || Enum.all?(validation_results, &(&1 == true)) do
:ok
else
error_results = consolidate_errors(validation_results)
{:error, {:validation, error_results}}
end
end
@spec consolidate_errors(list()) :: map()
defp consolidate_errors(validation_results) do
error_results = validation_results |> Enum.reject(&(&1 == true))
Enum.reduce(error_results, %{}, fn error_result, map ->
item_name = error_result |> Map.keys() |> List.first()
error_message = Map.get(error_result, item_name)
Map.put(map, item_name, [error_message | map[item_name] || []])
end)
end
@spec errors_message(map()) :: String.t()
def errors_message(errors) do
errors
|> Enum.map(fn {item_name, error_messages} ->
"#{item_name}: #{Enum.join(error_messages, "\n\t")}"
end)
|> Enum.join("\n")
end
@doc """
Validate received params over a contract. Accumulate validation results into a list.
## Examples
iex> Exop.Validation.validate([%{name: :param, opts: [required: true, type: :string]}], [param: "hello"], [])
[true, true]
"""
@spec validate([map()], map() | Keyword.t(), list()) :: list()
def validate([], _received_params, result), do: result
def validate([%{name: name, opts: opts} = contract_item | contract_tail], received_params, result) do
checks_result =
if !required?(opts) && !present?(received_params, name) do
[]
else
validate_params(contract_item, received_params)
end
validate(contract_tail, received_params, result ++ List.flatten(checks_result))
end
defp present?(received_params, contract_item_name) do
check_item_present?(received_params, contract_item_name)
end
defp required?(opts), do: opts[:required] != false
defp explicit_required(opts) when is_list(opts) do
if required?(opts), do: Keyword.put(opts, :required, true), else: opts
end
defp explicit_required(opts) when is_map(opts) do
if required?(opts), do: Map.put(opts, :required, true), else: opts
end
defp validate_params(%{name: name, opts: opts} = _contract_item, received_params) do
# see changelog for ver. 1.2.0: everything except `required: false` is `required: true`
opts = explicit_required(opts)
is_nil? =
check_item_present?(received_params, name) && is_nil(get_check_item(received_params, name))
if is_nil? do
if opts[:allow_nil] == true do
[]
else
[Exop.ValidationChecks.check_allow_nil(received_params, name, false)]
end
else
for {check_name, check_params} <- opts, into: [] do
check_function_name = String.to_atom("check_#{check_name}")
cond do
function_present?(__MODULE__, check_function_name) ->
apply(__MODULE__, check_function_name, [received_params, name, check_params])
function_present?(Exop.ValidationChecks, check_function_name) ->
apply(Exop.ValidationChecks, check_function_name, [received_params, name, check_params])
true ->
true
end
end
end
end
@doc """
Checks inner item of the contract param (which is a Map itself) with their own checks.
## Examples
iex> Exop.Validation.check_inner(%{a: 1}, :a, [b: [type: :atom], c: [type: :string]])
[%{a: "has wrong type"}]
iex> Exop.Validation.check_inner(%{a: []}, :a, [b: [type: :atom], c: [type: :string]])
[[%{"a[:b]" => "is required"}, true], [%{"a[:c]" => "is required"}, true]]
iex> Exop.Validation.check_inner(%{a: %{b: :atom, c: "string"}}, :a, [b: [type: :atom], c: [type: :string]])
[[true, true], [true, true]]
"""
@spec check_inner(map() | Keyword.t(), atom() | String.t(), map() | Keyword.t()) :: list
def check_inner(check_items, item_name, checks) do
checks = Utils.try_map(checks)
not is_map(checks) ||
check_items
|> get_check_item(item_name)
|> Utils.try_map()
|> do_check_inner(item_name, checks)
end
defp do_check_inner(check_item, item_name, checks) when is_map(check_item) do
received_params =
Enum.reduce(check_item, %{}, fn {inner_param_name, _inner_param_value}, acc ->
Map.put(acc, "#{item_name}[:#{inner_param_name}]", check_item[inner_param_name])
end)
for {inner_param_name, inner_opts} <- checks, into: [] do
inner_name = "#{item_name}[:#{inner_param_name}]"
validate_params(%{name: inner_name, opts: inner_opts}, received_params)
end
end
defp do_check_inner(_check_item, item_name, _checks), do: [%{item_name => "has wrong type"}]
@doc """
Checks items of the contract's list param with specified checks.
## Examples
iex> Exop.Validation.check_list_item(%{a: 1}, :a, [type: :integer])
[%{a: "is not a list"}]
iex> Exop.Validation.check_list_item(%{a: []}, :a, [type: :integer])
[]
iex> Exop.Validation.check_list_item(%{a: [1, :atom]}, :a, [type: :integer])
[[true, true], [true, %{"a[1]" => "has wrong type; expected type: integer, got: :atom"}]]
iex> Exop.Validation.check_list_item(%{a: [1, 2]}, :a, [type: :integer])
[[true, true], [true, true]]
"""
@spec check_list_item(map() | Keyword.t(), atom() | String.t(), map() | Keyword.t()) :: list
def check_list_item(check_items, item_name, checks) when is_list(checks) do
check_list_item(check_items, item_name, Enum.into(checks, %{}))
end
def check_list_item(check_items, item_name, checks) when is_map(checks) do
list = get_check_item(check_items, item_name)
if is_list(list) do
received_params =
list
|> Enum.with_index()
|> Enum.reduce(%{}, fn {item, index}, acc ->
Map.put(acc, "#{item_name}[#{index}]", item)
end)
for {param_name, _} <- received_params, into: [] do
validate_params(%{name: param_name, opts: checks}, received_params)
end
else
[%{String.to_atom("#{item_name}") => "is not a list"}]
end
end
end