Skip to content

Commit

Permalink
Implement new separate api for cast, validate and transform
Browse files Browse the repository at this point in the history
  • Loading branch information
bluzky committed Feb 26, 2024
1 parent 71b27c2 commit edd5348
Show file tree
Hide file tree
Showing 7 changed files with 647 additions and 102 deletions.
49 changes: 49 additions & 0 deletions lib/result.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
defmodule Tarams.Result do
@moduledoc """
Result Struct for Tarams operations
"""
@enforce_keys [:schema]
defstruct schema: %{},
valid_data: %{},
params: %{},
errors: %{},
valid?: true

@doc """
Create a new Result struct with given schema map and params.
"""
@spec new(%{}) :: %Tarams.Result{}
def new(attrs) do
struct(__MODULE__, attrs)
end

@doc """
Put error to result.
"""
@spec put_error(%Tarams.Result{}, field :: atom, error :: String.t()) :: %Tarams.Result{}
def put_error(result, field, error) do
errors =
case get_error(result, field) do
nil -> error
errors -> [error | errors]
end

%Tarams.Result{result | errors: Map.put(result.errors, field, errors), valid?: false}
end

@doc """
Put valid data to result.
"""
@spec put_data(%Tarams.Result{}, field :: atom, value :: any) :: %Tarams.Result{}
def put_data(result, field, value) do
%Tarams.Result{result | valid_data: Map.put(result.valid_data, field, value)}
end

@doc """
Get error from result for given field.
"""
@spec get_error(%Tarams.Result{}, field :: atom) :: String.t() | nil
def get_error(result, field) do
Map.get(result.errors, field)
end
end
6 changes: 6 additions & 0 deletions lib/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ defmodule Tarams.Schema do
```
"""

defmacro __using__(_) do
quote do
import Tarams.DefSchema
end
end

@doc """
Expand short-hand type syntax to full syntax
Expand Down
245 changes: 158 additions & 87 deletions lib/tarams.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Tarams do
"""

alias Tarams.Type
alias Tarams.Result

defdelegate plug_scrub(conn, keys \\ []), to: Tarams.Utils
defdelegate scrub_param(data), to: Tarams.Utils
Expand Down Expand Up @@ -31,23 +32,18 @@ defmodule Tarams do
```
"""

@spec cast(data :: map(), schema :: map()) :: {:ok, map()} | {:error, errors :: map()}
@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)}
Result.new(schema: schema, params: data)
|> do_cast()
|> do_validate()
|> do_transform()
|> case do
%Result{valid?: true, valid_data: valid_data} -> {:ok, valid_data}
%{errors: errors} -> {:error, errors}
end
end

Expand All @@ -58,28 +54,84 @@ defmodule Tarams do
end
end

defp cast_data(data, schema) when is_map(data) do
schema
|> Enum.map(&cast_field(data, &1))
|> collect_schema_result()
@doc """
Cast and validate params with given schema.
"""
@spec cast(data :: map(), schema :: map()) ::
%Tarams.Result{}
def cast_only(data, schema) when is_map(data) do
schema = schema |> Tarams.Schema.expand()

Result.new(schema: schema, params: data)
|> do_cast()
end

defp cast_data(_, _) do
{:error, "is invalid"}
defp do_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(data, schema) when is_map(data) do
schema = schema |> Tarams.Schema.expand()

Result.new(schema: schema, params: data, valid_data: data)
|> do_validate()
|> case do
%Result{valid?: true} -> :ok
%{errors: errors} -> {:error, errors}
end
end

defp do_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, {field_name, error}} -> Result.put_error(acc, field_name, error)
end
end
end)
end

defp validate_data(data, schema) do
schema
|> Enum.map(&validate_field(data, &1))
|> collect_schema_result()
@doc """
Transform params with given schema.
"""
def transform(data, schema) do
schema = schema |> Tarams.Schema.expand()

Result.new(schema: schema, params: data, valid_data: data)
|> do_transform()
|> case do
%Result{valid?: true, valid_data: valid_data} -> {:ok, valid_data}
%{errors: errors} -> {:error, errors}
end
end

defp transform_data(data, schema) do
schema
|> Enum.map(&transform_field(data, &1))
|> collect_schema_result()
defp do_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)

Expand Down Expand Up @@ -145,12 +197,18 @@ defmodule Tarams do

# cast array of custom map
defp cast_value(value, {:array, %{} = type}) do
cast_array({:embed, __MODULE__, type}, value)
cast_array(type, value)
end

# cast nested map
defp cast_value(value, %{} = type) when is_map(value) do
Type.cast({:embed, __MODULE__, type}, value)
case cast_only(value, type) do
%Result{valid?: true, valid_data: valid_data} ->
{:ok, valid_data}

%{errors: errors} ->
{:error, errors}
end
end

defp cast_value(_, %{}), do: :error
Expand All @@ -163,21 +221,24 @@ defmodule Tarams do
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
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)}

@validation_ignore [:into, :type, :cast_func, :default, :from, :message, :as]
## Validate schema
defp validate_field(data, {field_name, definitions}) do
value = get_value(data, field_name)
# remote transform option from definition
Keyword.drop(definitions, @validation_ignore)
definitions
|> Enum.map(fn validation ->
do_validate(value, data, validation)
do_validate(field_name, value, data, validation)
end)
|> collect_validation_result()
|> case do
Expand All @@ -188,38 +249,84 @@ defmodule Tarams do

# 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
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
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(value, type)
else
{:error, "is invalid"}
end
end

defp do_validate(_, value, _, {:type, {:array, type}}) when is_map(type) do
Enum.map(value, fn item ->
do_validate(nil, item, value, {:type, type})
end)
|> collect_validation_result()
end

# validate module
defp do_validate(_, value, _, {:type, type}) do
if is_struct(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(value, data, {:func, func}) do
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
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, 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
Expand Down Expand Up @@ -265,40 +372,4 @@ defmodule Tarams do
{: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
Loading

0 comments on commit edd5348

Please sign in to comment.