Current section
Files
Jump to
Current section
Files
lib/skema.ex
defmodule Skema do
@moduledoc """
Params provide some helpers method to work with parameters
"""
alias Skema.Result
alias Skema.Type
@doc """
Cast and validate params with given schema.
See `Skema.SchemaHelper` 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} <- Skema.cast(params, index_schema) do
# do query data
else
{:error, errors} -> IO.puts(errors)
end
end
```
"""
@spec cast_and_validate(data :: map(), schema :: map()) ::
{:ok, map()} | {:error, errors :: map()}
def cast_and_validate(data, schema) do
schema = Skema.SchemaHelper.expand(schema)
[schema: schema, params: data]
|> Result.new()
|> cast()
|> validate()
|> transform()
|> case do
%Result{valid?: true, valid_data: valid_data} -> {:ok, valid_data}
%{errors: errors} -> {:error, errors}
end
end
def cast_and_validate!(data, schema) do
case cast_and_validate(data, schema) do
{:ok, value} -> value
_ -> raise "Skema :: bad input data"
end
end
@doc """
Cast and validate params with given schema.
"""
@spec cast_apply(data :: map(), schema :: map()) ::
%Skema.Result{}
def cast_apply(data, schema) when is_map(data) do
schema = Skema.SchemaHelper.expand(schema)
[schema: schema, params: data]
|> Result.new()
|> cast()
|> case do
%{valid?: true, valid_data: data} -> {:ok, data}
%{errors: errors} -> {:error, errors}
end
end
@spec cast(data :: map(), schema :: map) :: %Skema.Result{}
def cast(data, schema) when is_map(data) do
schema = Skema.SchemaHelper.expand(schema)
[schema: schema, params: data]
|> Result.new()
|> cast()
end
@spec cast(%Skema.Result{}) :: %Skema.Result{}
defp cast(%Result{} = result) do
Enum.reduce(result.schema, result, fn field, acc ->
case cast_field(acc.params, field) do
{:ok, {field_name, value}} -> Result.put_data(acc, field_name, value)
{:error, {field_name, error}} -> Result.put_error(acc, field_name, error)
end
end)
end
@doc """
Validate params with given schema.
"""
def validate_apply(data, schema) when is_map(data) do
schema = Skema.SchemaHelper.expand(schema)
[schema: schema, params: data, valid_data: data]
|> Result.new()
|> validate()
|> case do
%Result{valid?: true} -> :ok
%{errors: errors} -> {:error, errors}
end
end
def validate(data, schema) when is_map(data) do
schema = Skema.SchemaHelper.expand(schema)
[schema: schema, params: data, valid_data: data]
|> Result.new()
|> validate()
end
def validate(%Result{} = result) do
Enum.reduce(result.schema, result, fn {field_name, _} = field, acc ->
# skip if there is an error
if Result.get_error(acc, field_name) do
acc
else
case validate_field(acc.valid_data, field) do
:ok -> acc
{:error, error} -> Result.put_error(acc, field_name, error)
end
end
end)
end
@doc """
Transform params with given schema.
"""
def transform_apply(data, schema) do
schema = Skema.SchemaHelper.expand(schema)
[schema: schema, params: data, valid_data: data]
|> Result.new()
|> transform()
|> case do
%Result{valid?: true, valid_data: valid_data} -> {:ok, valid_data}
%{errors: errors} -> {:error, errors}
end
end
def transform(data, schema) do
schema = Skema.SchemaHelper.expand(schema)
[schema: schema, params: data, valid_data: data]
|> Result.new()
|> transform()
end
def transform(%Result{} = result) do
Enum.reduce(result.schema, result, fn {field_name, _} = field, acc ->
# skip if there is an error
if Result.get_error(acc, field_name) do
acc
else
case transform_field(acc.valid_data, field) do
{:ok, {field_name, value}} -> Result.put_data(acc, field_name, value)
{:error, {field_name, error}} -> Result.put_error(acc, field_name, error)
end
end
end)
end
## cast schema logic
defp cast_field(data, {field_name, definitions}) do
{custom_message, definitions} = Keyword.pop(definitions, :message)
# 1. cast value
case do_cast(data, field_name, definitions) do
{:ok, value} ->
{:ok, {field_name, value}}
{: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(type, value)
end
# cast nested map
defp cast_value(value, %{} = type) when is_map(value) do
case cast(value, type) do
%Result{valid?: true, valid_data: valid_data} ->
{:ok, valid_data}
%{errors: errors} ->
{:error, errors}
end
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 cast_value(value, type) do
{:ok, data} ->
cast_array(type, t, [data | acc])
error ->
error
end
end
def cast_array(_, [], acc), do: {:ok, Enum.reverse(acc)}
## Validate schema
defp validate_field(data, {field_name, definitions}) do
value = get_value(data, field_name)
# remote transform option from definition
definitions
|> Enum.map(fn validation ->
do_validate(field_name, value, data, validation)
end)
|> collect_validation_result()
end
# handle custom validation for required
# Support dynamic require validation
defp do_validate(_, value, data, {:required, required}) when is_function(required) or is_tuple(required) do
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
defp do_validate(_, value, _data, {:required, required}) do
Valdi.validate(value, [{:required, required}])
end
# skip validation for nil
defp do_validate(_, nil, _, _), do: :ok
# validate type
defp do_validate(_, value, _, {:type, type}) when is_map(type) do
# validate nested map
if is_map(value) do
validate_apply(value, type)
else
{:error, "is invalid"}
end
end
defp do_validate(_, value, _, {:type, {:array, type}}) when is_list(value) do
value
|> Enum.map(fn item ->
do_validate(nil, item, value, {:type, type})
end)
|> Enum.reverse()
|> collect_validation_result()
end
# validate module
defp do_validate(_, value, _, {:type, type}) do
if is_atom(type) and Kernel.function_exported?(type, :validate, 1) do
type.validate(value)
else
Valdi.validate(value, [{:type, type}])
end
end
# support custom validate fuction with whole data
defp do_validate(field_name, 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, 3) -> func.(field_name, 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 collect_validation_result(results) do
summary =
Enum.reduce(results, {:ok, []}, fn
:ok, acc -> acc
{:error, %Skema.Result{errors: errors}}, {_, acc_msg} -> {:error, [[errors] | acc_msg]}
{:error, msg}, {_, acc_msg} when is_list(msg) -> {:error, [msg | acc_msg]}
{:error, msg}, {_, acc_msg} -> {:error, [[msg] | acc_msg]}
end)
case summary do
{:ok, _} ->
:ok
{:error, errors} ->
{:error, Enum.concat(errors)}
end
end
## Transform schema
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
end