defmodule Tarams do
@moduledoc """
Params provide some helpers method to work with parameters
"""
alias Tarams.Type
defdelegate plug_scrub(conn, keys \\ []), to: Tarams.Utils
defdelegate scrub_param(data), to: Tarams.Utils
defdelegate clean_nil(data), to: Tarams.Utils
@doc """
Cast and validate params with given schema.
See `Tarams.Schema` for instruction on how to define a schema
And then use it like this
```elixir
def index(conn, params) do
index_schema = %{
status: [type: :string, required: true],
type: [type: :string, in: ["type1", "type2", "type3"]],
keyword: [type: :string, length: [min: 3, max: 100]],
}
with {:ok, data} <- Tarams.cast(params, index_schema) do
# do query data
else
{:error, errors} -> IO.puts(errors)
end
end
```
"""
@spec cast(data :: map(), schema :: map()) :: {:ok, map()} | {:error, errors :: map()}
def cast(data, schema) do
schema = schema |> Tarams.Schema.expand()
with {:cast, {:ok, data, _}} <- {:cast, cast_data(data, schema)},
data <- Map.new(data),
{:ok, _, _} <- validate_data(data, schema),
{:ok, data, _} <- transform_data(data, schema) do
{:ok, Map.new(data)}
else
{:cast, {_, data, errors}} ->
# validate casted valid data for full error report
{_, _, v_errors} = validate_data(Map.new(data), schema)
{:error, Map.new(v_errors ++ errors)}
{:error, _, errors} ->
{:error, Map.new(errors)}
end
end
def cast!(data, schema) do
case cast(data, schema) do
{:ok, value} -> value
_ -> raise "Tarams :: bad input data"
end
end
defp cast_data(data, schema) when is_map(data) do
schema
|> Enum.map(&cast_field(data, &1))
|> collect_schema_result()
end
defp cast_data(_, _) do
{:error, "is invalid"}
end
defp validate_data(data, schema) do
schema
|> Enum.map(&validate_field(data, &1))
|> collect_schema_result()
end
defp transform_data(data, schema) do
schema
|> Enum.map(&transform_field(data, &1))
|> collect_schema_result()
end
defp cast_field(data, {field_name, definitions}) do
{custom_message, definitions} = Keyword.pop(definitions, :message)
# 1. cast value
with {:ok, value} <- do_cast(data, field_name, definitions) do
{:ok, {field_name, value}}
else
{:error, error} ->
# 3.2 Handle custom error message
if custom_message do
{:error, {field_name, [custom_message]}}
else
errors = if is_binary(error), do: [error], else: error
{:error, {field_name, errors}}
end
end
end
# cast data to proper type
defp do_cast(data, field_name, definitions) do
field_name =
if definitions[:from] do
definitions[:from]
else
field_name
end
value = get_value(data, field_name, definitions[:default])
cast_result =
case definitions[:cast_func] do
nil ->
cast_value(value, definitions[:type])
func ->
apply_function(func, value, data)
end
case cast_result do
:error -> {:error, "is invalid"}
others -> others
end
end
defp get_value(data, field_name, default \\ nil) do
case Map.fetch(data, field_name) do
{:ok, value} ->
value
_ ->
case Map.fetch(data, "#{field_name}") do
{:ok, value} ->
value
_ ->
default
end
end
end
defp cast_value(nil, _), do: {:ok, nil}
# cast array of custom map
defp cast_value(value, {:array, %{} = type}) do
cast_array({:embed, __MODULE__, type}, value)
end
# cast nested map
defp cast_value(value, %{} = type) when is_map(value) do
Type.cast({:embed, __MODULE__, type}, value)
end
defp cast_value(_, %{}), do: :error
defp cast_value(value, type) do
Type.cast(type, value)
end
# rewrite cast_array for more detail errors
def cast_array(type, value, acc \\ [])
def cast_array(type, [value | t], acc) do
case Type.cast(type, value) do
{:ok, data} -> cast_array(type, t, [data | acc])
error -> error
end
end
def cast_array(_, [], acc), do: {:ok, Enum.reverse(acc)}
@validation_ignore [:into, :type, :cast_func, :default, :from, :message, :as]
defp validate_field(data, {field_name, definitions}) do
value = get_value(data, field_name)
# remote transform option from definition
Keyword.drop(definitions, @validation_ignore)
|> Enum.map(fn validation ->
do_validate(value, data, validation)
end)
|> collect_validation_result()
|> case do
{:error, errors} -> {:error, {field_name, errors}}
:ok -> :ok
end
end
# handle custom validation for required
# Support dynamic require validation
defp do_validate(value, data, {:required, required}) do
if is_boolean(required) do
Valdi.validate(value, [{:required, required}])
else
case apply_function(required, value, data) do
{:error, _} = error ->
error
rs ->
is_required = rs not in [false, nil]
Valdi.validate(value, [{:required, is_required}])
end
end
end
# skip validation for nil
defp do_validate(nil, _, _), do: :ok
# support custom validate fuction with whole data
defp do_validate(value, data, {:func, func}) do
case func do
{mod, func} -> apply(mod, func, [value, data])
{mod, func, args} -> apply(mod, func, args ++ [value, data])
func when is_function(func) -> func.(value)
_ -> {:error, "invalid custom validation function"}
end
end
defp do_validate(value, _, validator) do
Valdi.validate(value, [validator])
end
defp transform_field(data, {field_name, definitions}) do
value = get_value(data, field_name)
field_name = definitions[:as] || field_name
result =
case definitions[:into] do
nil ->
{:ok, value}
func ->
apply_function(func, value, data)
end
# support function return tuple or value
case result do
{status, value} when status in [:error, :ok] -> {status, {field_name, value}}
value -> {:ok, {field_name, value}}
end
end
# Apply custom function for validate, cast, and required
defp apply_function(func, value, data) do
case func do
{mod, func} ->
cond do
Kernel.function_exported?(mod, func, 1) ->
apply(mod, func, [value])
Kernel.function_exported?(mod, func, 2) ->
apply(mod, func, [value, data])
true ->
{:error, "bad function"}
end
func when is_function(func, 2) ->
func.(value, data)
func when is_function(func, 1) ->
func.(value)
_ ->
{:error, "bad function"}
end
end
defp collect_validation_result(results) do
summary =
Enum.reduce(results, :ok, fn
:ok, acc -> acc
{:error, msg}, :ok -> {:error, [msg]}
{:error, msg}, {:error, acc_msg} -> {:error, [msg | acc_msg]}
end)
case summary do
:ok ->
:ok
{:error, errors} ->
errors =
errors
|> Enum.map(fn item ->
if is_list(item) do
item
else
[item]
end
end)
|> Enum.concat()
{:error, errors}
end
end
defp collect_schema_result(results) do
Enum.reduce(results, {:ok, [], []}, fn
{:ok, value}, {status, data, errors} -> {status, [value | data], errors}
{:error, {k, v}}, {_, data, errors} -> {:error, [{k, nil} | data], [{k, v} | errors]}
_, acc -> acc
end)
end
end