diff --git a/examples/contract/types-01.exs b/examples/contract/types-01.exs index 25dc8ca..0e77cdc 100644 --- a/examples/contract/types-01.exs +++ b/examples/contract/types-01.exs @@ -1,16 +1,16 @@ -Drops.Types.from_spec({:type, {:string, []}}, []) +Drops.Type.Compiler.visit({:type, {:string, []}}, []) -Drops.Types.from_spec({:type, {:string, [:filled?]}}, []) +Drops.Type.Compiler.visit({:type, {:string, [:filled?]}}, []) -Drops.Types.from_spec({:type, {:list, []}}, []) +Drops.Type.Compiler.visit({:type, {:list, []}}, []) -Drops.Types.from_spec({:type, {:list, {:type, {:integer, []}}}}, []) +Drops.Type.Compiler.visit({:type, {:list, {:type, {:integer, []}}}}, []) -Drops.Types.from_spec([{:type, {:string, []}}, {:type, {:integer, []}}], []) +Drops.Type.Compiler.visit([{:type, {:string, []}}, {:type, {:integer, []}}], []) -Drops.Types.from_spec({:type, {:map, []}}, []) +Drops.Type.Compiler.visit({:type, {:map, []}}, []) -Drops.Types.from_spec( +Drops.Type.Compiler.visit( %{ {:required, :name} => {:type, {:string, []}}, {:optional, :age} => {:type, {:string, []}} @@ -18,7 +18,7 @@ Drops.Types.from_spec( [] ) -Drops.Types.from_spec( +Drops.Type.Compiler.visit( {:cast, {{:type, {:integer, []}}, {:type, {:date_time, []}}, [:miliseconds]}}, [] ) diff --git a/examples/types/custom-01.ex b/examples/types/custom-01.ex new file mode 100644 index 0000000..563e3b6 --- /dev/null +++ b/examples/types/custom-01.ex @@ -0,0 +1,30 @@ +defmodule FilledString do + use Drops.Type, {:string, [:filled?]} +end + +defmodule UserContract do + use Drops.Contract + + schema do + %{ + required(:name) => FilledString + } + end +end + +UserContract.conform(%{name: "Jane Doe"}) +# {:ok, %{name: "Jane Doe"}} + +{:error, errors} = UserContract.conform(%{name: 1}) +Enum.map(errors, &to_string/1) +# ["name must be a string"] + +{:error, errors} = UserContract.conform(%{name: ""}) +Enum.map(errors, &to_string/1) +# ["name must be filled"] + +[%{type: type}]= UserContract.schema().keys +# %FilledString{ +# primitive: :string, +# constraints: {:and, [predicate: {:type?, :string}, predicate: {:filled?, []}]} +# } diff --git a/lib/drops/casters.ex b/lib/drops/casters.ex index 68a302a..3078e4c 100644 --- a/lib/drops/casters.ex +++ b/lib/drops/casters.ex @@ -3,7 +3,7 @@ defmodule Drops.Casters do Drops.Casters is a module that provides functions for casting values from one type to another. - This module is the default caster module used by the Drops.Types.Map.DSL.cast function. + This module is the default caster module used by the Drops.Type.DSL.cast function. """ @doc ~S""" diff --git a/lib/drops/contract.ex b/lib/drops/contract.ex index 99987a1..5f16388 100644 --- a/lib/drops/contract.ex +++ b/lib/drops/contract.ex @@ -44,7 +44,7 @@ defmodule Drops.Contract do use Drops.Validator import Drops.Contract - import Drops.Types.Map.DSL + import Drops.Type.DSL @behaviour Drops.Contract @@ -55,70 +55,44 @@ defmodule Drops.Contract do @before_compile Drops.Contract def conform(data) do - conform(data, schema(), root: true) - end - - def conform(data, %Types.Map{atomize: true} = schema, root: root) do - conform(Types.Map.atomize(data, schema.keys), schema.keys, root: root) - end - - def conform(data, %Types.Map{} = schema, root: root) do - case validate(data, schema) do - {:ok, {_, validated_data}} -> - conform(validated_data, schema.keys, root: root) - - error -> - {:error, @message_backend.errors(error)} - end - end - - def conform(data, %Types.Sum{} = type, root: root) do - case conform(data, type.left, root: root) do - {:ok, value} -> - {:ok, value} - - {:error, _} = left_errors -> - case conform(data, type.right, root: root) do - {:ok, value} -> - {:ok, value} - - {:error, error} = right_errors -> - {:error, @message_backend.errors({:or, {left_errors, right_errors}})} - end - end + conform(data, schema(), path: []) end def conform(data, %Types.Map{} = schema, path: path) do - case conform(data, schema, root: false) do - {:ok, value} -> - {:ok, {path, value}} - - {:error, errors} -> - {:error, nest_errors(errors, path)} - end - end + case Drops.Type.Validator.validate(schema, data) do + {outcome, {:map, items}} = result -> + output = to_output(result) + errors = if outcome == :ok, do: [], else: Enum.reject(items, &is_ok/1) - def conform(data, keys, root: root) when is_list(keys) do - results = validate(data, keys) - output = to_output(results) - errors = Enum.reject(results, &is_ok/1) + all_errors = if Enum.empty?(path), do: errors ++ apply_rules(output), else: errors - all_errors = if root, do: errors ++ apply_rules(output), else: errors + if length(all_errors) > 0 do + {:error, @message_backend.errors(all_errors)} + else + {:ok, output} + end - if length(all_errors) > 0 do - {:error, @message_backend.errors(collapse_errors(all_errors))} - else - {:ok, output} + {:error, meta} -> + {:error, @message_backend.errors({:error, {path, meta}})} end end - def validate(value, %Types.Map{} = schema, path: path) do - case validate(value, schema.constraints, path: path) do - {:ok, {_, validated_value}} -> - conform(validated_value, schema, path: path) - - error -> - error + def conform(data, %Types.Sum{} = type, path: path) do + case conform(data, type.left, path: path) do + {:ok, output} = success -> + success + + {:error, left_error} -> + case conform(data, type.right, path: path) do + {:ok, output} = success -> + success + + {:error, right_error} -> + {:error, + @message_backend.errors( + {:error, {path, {:or, {left_error, right_error}}}} + )} + end end end @@ -339,60 +313,56 @@ defmodule Drops.Contract do end end - def collapse_errors(errors) when is_list(errors) do - Enum.map(errors, fn - {:error, {path, name, args}} -> - {:error, {path, name, args}} + def to_output({_, {:map, [head | tail]}}) do + to_output(tail, to_output(head, %{})) + end - {:error, error_list} -> - collapse_errors(error_list) + def to_output({_, {:list, results}}) do + to_output(results) + end + + def to_output({:list, results}) do + to_output(results) + end - {:or, {left_errors, right_errors}} -> - {:or, {collapse_errors(left_errors), collapse_errors(right_errors)}} + def to_output({:map, results}) do + to_output(results, %{}) + end - result -> - result - end) - |> List.flatten() + def to_output([head | tail]) do + [to_output(head) | to_output(tail)] end - def collapse_errors({:error, errors}) do - {:error, collapse_errors(errors)} + def to_output({:ok, value}) do + to_output(value) end - def collapse_errors(errors), do: errors + def to_output(value) do + value + end - def nest_errors(errors, root) do - List.flatten(Enum.map(errors, &Messages.Error.Conversions.nest(&1, root))) + def to_output(:ok, output) do + output end - def map_list_results(members) do - Enum.map(members, fn member -> - case member do - {:ok, {_, value}} -> - if is_list(value), do: map_list_results(value), else: value + def to_output([], output) do + output + end - value -> - value - end - end) + def to_output({:ok, {path, result}}, output) do + put_in(output, Enum.map(path, &Access.key(&1, %{})), to_output(result)) end - def to_output(results) do - Enum.reduce(results, %{}, fn result, acc -> - case result do - {:ok, {path, value}} -> - if is_list(value), - do: put_in(acc, path, map_list_results(value)), - else: put_in(acc, path, value) + def to_output({:error, _}, output) do + output + end - :ok -> - acc + def to_output({:list, results}, output) do + to_output(results, output) + end - {:error, _} -> - acc - end - end) + def to_output([head | tail], output) do + to_output(tail, to_output(head, output)) end defp set_schema(_caller, name, opts, block) do @@ -407,7 +377,7 @@ defmodule Drops.Contract do Map.put( schemas, unquote(name), - Drops.Types.from_spec(unquote(block), unquote(opts)) + Drops.Type.Compiler.visit(unquote(block), unquote(opts)) ) ) end diff --git a/lib/drops/predicates/helpers.ex b/lib/drops/predicates/helpers.ex new file mode 100644 index 0000000..3447744 --- /dev/null +++ b/lib/drops/predicates/helpers.ex @@ -0,0 +1,34 @@ +defmodule Drops.Predicates.Helpers do + alias Drops.Predicates + + def apply_predicates(value, {:and, predicates}) do + apply_predicates(value, predicates) + end + + def apply_predicates(value, predicates) do + Enum.reduce(predicates, {:ok, value}, &apply_predicate(&1, &2)) + end + + def apply_predicate({:predicate, {name, args}}, {:ok, value}) do + apply_args = + case args do + [arg] -> [arg, value] + [] -> [value] + arg -> [arg, value] + end + + if apply(Predicates, name, apply_args) do + {:ok, value} + else + if is_list(value) do + {:error, [input: value, predicate: name, args: apply_args]} + else + {:error, {value, predicate: name, args: apply_args}} + end + end + end + + def apply_predicate(_, {:error, _} = error) do + error + end +end diff --git a/lib/drops/type.ex b/lib/drops/type.ex new file mode 100644 index 0000000..5d31d81 --- /dev/null +++ b/lib/drops/type.ex @@ -0,0 +1,114 @@ +defmodule Drops.Type do + @moduledoc ~S""" + Type behaviour + """ + + alias __MODULE__ + + defmacro __using__(do: block) do + quote do + import Drops.Type + import Drops.Type.DSL + + unquote(block) + end + end + + defmacro __using__(spec) do + quote do + import Drops.Type + import Drops.Type.DSL + + deftype( + primitive: Type.infer_primitive(unquote(spec)), + constraints: Type.infer_constraints(unquote(spec)) + ) + + def new(attributes) when is_list(attributes) do + struct(__MODULE__, attributes) + end + + def new(spec) do + new( + primitive: infer_primitive(spec), + constraints: infer_constraints(spec) + ) + end + + def new(spec, constraints) when is_list(constraints) do + new( + primitive: infer_primitive(spec), + constraints: infer_constraints({:type, {spec, constraints}}) + ) + end + + defoverridable new: 1 + + defimpl Drops.Type.Validator, for: __MODULE__ do + def validate(type, value) do + Drops.Predicates.Helpers.apply_predicates(value, type.constraints) + end + end + end + end + + defmacro deftype(primitive) when is_atom(primitive) do + quote do + deftype( + primitive: unquote(primitive), + constraints: type(unquote(primitive)) + ) + end + end + + defmacro deftype(attributes) when is_list(attributes) do + quote do + alias __MODULE__ + + @type t :: %__MODULE__{} + + defstruct(unquote(attributes)) + end + end + + defmacro deftype(primitive, attributes) when is_atom(primitive) do + all_attrs = + [primitive: primitive, constraints: Type.infer_constraints(primitive)] ++ + attributes + + quote do + deftype(unquote(all_attrs)) + end + end + + def infer_primitive([]), do: :any + def infer_primitive(name) when is_atom(name), do: name + def infer_primitive({:type, {name, _}}), do: name + + def infer_constraints([]), do: [] + def infer_constraints(type) when is_atom(type), do: [predicate(:type?, [type])] + + def infer_constraints(predicates) when is_list(predicates) do + Enum.map(predicates, &predicate/1) + end + + def infer_constraints({:type, {type, predicates}}) when length(predicates) > 0 do + {:and, [predicate(:type?, type) | Enum.map(predicates, &predicate/1)]} + end + + def infer_constraints({:type, {type, []}}) do + [predicate(:type?, type)] + end + + def predicate({name, args}) do + predicate(name, args) + end + + def predicate(name) do + predicate(name, []) + end + + def predicate(name, args) do + {:predicate, {name, args}} + end +end diff --git a/lib/drops/type/compiler.ex b/lib/drops/type/compiler.ex new file mode 100644 index 0000000..3aad90d --- /dev/null +++ b/lib/drops/type/compiler.ex @@ -0,0 +1,62 @@ +defmodule Drops.Type.Compiler do + @moduledoc ~S""" + Drops.Type.Compiler is a module that provides functions for creating type structs + from DSL's type specs represented by plain tuples. + """ + alias Drops.Types.{ + Primitive, + Sum, + List, + Cast, + Map, + Map.Key + } + + def visit(type, _opts) when is_struct(type), do: type + + def visit(%{} = spec, opts) do + keys = + Enum.map(spec, fn {{presence, name}, type_spec} -> + %Key{path: [name], presence: presence, type: visit(type_spec, opts)} + end) + + Map.new(keys, opts) + end + + def visit({:sum, {left, right}}, opts) do + Sum.new(visit(left, opts), visit(right, opts)) + end + + def visit({:type, {:list, member_type}}, opts) + when is_tuple(member_type) or is_map(member_type) do + List.new(visit(member_type, opts)) + end + + def visit({:type, {:list, predicates}}, opts) do + List.new(visit({:type, {:any, []}}, opts), predicates) + end + + def visit({:cast, {input_type, output_type, cast_opts}}, opts) do + Cast.new(visit(input_type, opts), visit(output_type, opts), cast_opts) + end + + def visit([left, right], opts) when is_tuple(left) and is_tuple(right) do + Sum.new(visit(left, opts), visit(right, opts)) + end + + def visit([left, right], opts) when is_map(left) and is_map(right) do + Sum.new(visit(left, opts), visit(right, opts)) + end + + def visit([left, right], _opts) do + Sum.new(left, right) + end + + def visit(mod, opts) when is_atom(mod) do + mod.new(opts) + end + + def visit(spec, _opts) when is_tuple(spec) do + Primitive.new(spec) + end +end diff --git a/lib/drops/types/map/dsl.ex b/lib/drops/type/dsl.ex similarity index 99% rename from lib/drops/types/map/dsl.ex rename to lib/drops/type/dsl.ex index f006368..432a71c 100644 --- a/lib/drops/types/map/dsl.ex +++ b/lib/drops/type/dsl.ex @@ -1,4 +1,4 @@ -defmodule Drops.Types.Map.DSL do +defmodule Drops.Type.DSL do @moduledoc """ DSL functions for defining map key and value type specifications. diff --git a/lib/drops/type/validator.ex b/lib/drops/type/validator.ex new file mode 100644 index 0000000..044b053 --- /dev/null +++ b/lib/drops/type/validator.ex @@ -0,0 +1,8 @@ +defprotocol Drops.Type.Validator do + @moduledoc ~S""" + Protocol for validating input using types + """ + + @spec validate(struct(), any()) :: {:ok, any()} | {:error, {any(), keyword()}} + def validate(type, value) +end diff --git a/lib/drops/types.ex b/lib/drops/types.ex deleted file mode 100644 index 1af8fe0..0000000 --- a/lib/drops/types.ex +++ /dev/null @@ -1,110 +0,0 @@ -defmodule Drops.Types do - @moduledoc ~S""" - Drops.Types is a module that provides functions for creating type structs - from DSL's type specs represented by plain tuples. - """ - alias Drops.Types.{ - Type, - Sum, - List, - Cast, - Map, - Map.Key - } - - def from_spec(%{primitive: _} = type, _opts) do - type - end - - def from_spec(%{} = spec, opts) do - atomize = opts[:atomize] || false - - keys = - Enum.map(spec, fn {{presence, name}, type_spec} -> - case type_spec do - %{primitive: _} -> - %Key{path: [name], presence: presence, type: type_spec} - - _ -> - %Key{path: [name], presence: presence, type: from_spec(type_spec, opts)} - end - end) - - %Map{ - primitive: :map, - constraints: infer_constraints({:type, {:map, []}}, opts), - atomize: atomize, - keys: keys - } - end - - def from_spec({:sum, {left, right}}, opts) do - %Sum{left: from_spec(left, opts), right: from_spec(right, opts), opts: opts} - end - - def from_spec({:type, {:list, member_type}} = spec, opts) - when is_tuple(member_type) or is_map(member_type) do - %List{ - primitive: :list, - constraints: infer_constraints(spec, opts), - member_type: from_spec(member_type, opts) - } - end - - def from_spec({:cast, {input_type, output_type, cast_opts}}, opts) do - %Cast{ - input_type: from_spec(input_type, opts), - output_type: from_spec(output_type, opts), - opts: cast_opts - } - end - - def from_spec([left, right], opts) when is_tuple(left) and is_tuple(right) do - %Sum{left: from_spec(left, opts), right: from_spec(right, opts), opts: opts} - end - - def from_spec([left, right], opts) when is_map(left) and is_map(right) do - %Sum{left: from_spec(left, opts), right: from_spec(right, opts), opts: opts} - end - - def from_spec([left, right], opts) do - %Sum{left: left, right: right, opts: opts} - end - - def from_spec(spec, opts) do - %Type{ - primitive: infer_primitive(spec, opts), - constraints: infer_constraints(spec, opts) - } - end - - def infer_primitive({:type, {type, _}}, _opts) do - type - end - - def infer_constraints({:type, {:list, member_type}}, _opts) - when is_tuple(member_type) or is_map(member_type) do - [predicate(:type?, :list)] - end - - def infer_constraints({:type, {type, predicates}}, _opts) - when length(predicates) > 0 do - {:and, [predicate(:type?, type) | Enum.map(predicates, &predicate/1)]} - end - - def infer_constraints({:type, {type, []}}, _opts) do - [predicate(:type?, type)] - end - - def predicate({name, args}) do - predicate(name, args) - end - - def predicate(name) do - predicate(name, []) - end - - def predicate(name, args) do - {:predicate, {name, args}} - end -end diff --git a/lib/drops/types/cast.ex b/lib/drops/types/cast.ex index dfe5010..f57c415 100644 --- a/lib/drops/types/cast.ex +++ b/lib/drops/types/cast.ex @@ -4,16 +4,16 @@ defmodule Drops.Types.Cast do ## Examples - iex> Drops.Types.from_spec( + iex> Drops.Type.Compiler.visit( ...> {:cast, {{:type, {:integer, []}}, {:type, {:date_time, []}}, [:miliseconds]}}, ...> [] ...> ) %Drops.Types.Cast{ - input_type: %Drops.Types.Type{ + input_type: %Drops.Types.Primitive{ primitive: :integer, constraints: [predicate: {:type?, :integer}] }, - output_type: %Drops.Types.Type{ + output_type: %Drops.Types.Primitive{ primitive: :date_time, constraints: [predicate: {:type?, :date_time}] }, @@ -21,5 +21,39 @@ defmodule Drops.Types.Cast do } """ - defstruct [:input_type, :output_type, :opts] + + alias Drops.Type.Validator + alias Drops.Casters + + use Drops.Type do + deftype([:input_type, :output_type, opts: []]) + + def new(input_type, output_type, opts) do + struct(__MODULE__, input_type: input_type, output_type: output_type, opts: opts) + end + end + + defimpl Validator do + def validate( + %{input_type: input_type, output_type: output_type, opts: cast_opts}, + value + ) do + caster = cast_opts[:caster] || Casters + + case Validator.validate(input_type, value) do + {:ok, result} -> + casted_value = + apply( + caster, + :cast, + [input_type.primitive, output_type.primitive, result] ++ cast_opts + ) + + Validator.validate(output_type, casted_value) + + {:error, error} -> + {:error, {:cast, error}} + end + end + end end diff --git a/lib/drops/types/list.ex b/lib/drops/types/list.ex index ee6040e..a2f1b50 100644 --- a/lib/drops/types/list.ex +++ b/lib/drops/types/list.ex @@ -5,18 +5,77 @@ defmodule Drops.Types.List do ## Examples - iex> Drops.Types.from_spec({:type, {:list, []}}, []) - %Drops.Types.Type{primitive: :list, constraints: [predicate: {:type?, :list}]} + iex> Drops.Type.Compiler.visit({:type, {:list, []}}, []) + %Drops.Types.Primitive{primitive: :list, constraints: [predicate: {:type?, :list}]} - iex> Drops.Types.from_spec({:type, {:list, {:type, {:integer, []}}}}, []) + iex> Drops.Type.Compiler.visit({:type, {:list, {:type, {:integer, []}}}}, []) %Drops.Types.List{ primitive: :list, constraints: [predicate: {:type?, :list}], - member_type: %Drops.Types.Type{ + member_type: %Drops.Types.Primitive{ primitive: :integer, constraints: [predicate: {:type?, :integer}] } } """ - defstruct [:primitive, :constraints, :member_type] + + alias Drops.Predicates + alias Drops.Type.Validator + + use Drops.Type do + deftype(:list, member_type: nil) + + def new(member_type, constraints \\ []) when is_struct(member_type) do + struct(__MODULE__, + member_type: member_type, + constraints: Drops.Type.infer_constraints(:list) ++ infer_constraints(constraints) + ) + end + end + + defimpl Validator, for: List do + def validate(%{constraints: constraints, member_type: member_type}, data) do + case apply_predicates(data, constraints) do + {:ok, members} -> + results = Enum.map(members, &Validator.validate(member_type, &1)) + errors = Enum.reject(results, &is_ok/1) + + if Enum.empty?(errors), + do: {:ok, {:list, results}}, + else: {:error, {:list, results}} + + {:error, result} -> + {:error, {:list, result}} + end + end + + defp apply_predicates(value, predicates) do + Enum.reduce(predicates, {:ok, value}, &apply_predicate(&1, &2)) + end + + defp apply_predicate({:predicate, {name, args}}, {:ok, value}) do + apply_args = + case args do + [arg] -> [arg, value] + [] -> [value] + arg -> [arg, value] + end + + if apply(Predicates, name, apply_args) do + {:ok, value} + else + {:error, {value, predicate: name, args: apply_args}} + end + end + + defp apply_predicate(_, {:error, _} = error) do + error + end + + defp is_ok(results) when is_list(results), do: Enum.all?(results, &is_ok/1) + defp is_ok(:ok), do: true + defp is_ok({:ok, _}), do: true + defp is_ok(:error), do: false + defp is_ok({:error, _}), do: false + end end diff --git a/lib/drops/types/map.ex b/lib/drops/types/map.ex index 7183b8a..672794e 100644 --- a/lib/drops/types/map.ex +++ b/lib/drops/types/map.ex @@ -4,10 +4,10 @@ defmodule Drops.Types.Map do ## Examples - iex> Drops.Types.from_spec({:type, {:map, []}}, []) - %Drops.Types.Type{primitive: :map, constraints: [predicate: {:type?, :map}]} + iex> Drops.Type.Compiler.visit({:type, {:map, []}}, []) + %Drops.Types.Primitive{primitive: :map, constraints: [predicate: {:type?, :map}]} - iex> Drops.Types.from_spec(%{ + iex> Drops.Type.Compiler.visit(%{ ...> {:required, :name} => {:type, {:string, []}}, ...> {:optional, :age} => {:type, {:integer, []}} ...> }, []) @@ -18,7 +18,7 @@ defmodule Drops.Types.Map do %Drops.Types.Map.Key{ path: [:age], presence: :optional, - type: %Drops.Types.Type{ + type: %Drops.Types.Primitive{ primitive: :integer, constraints: [predicate: {:type?, :integer}] } @@ -26,7 +26,7 @@ defmodule Drops.Types.Map do %Drops.Types.Map.Key{ path: [:name], presence: :required, - type: %Drops.Types.Type{ + type: %Drops.Types.Primitive{ primitive: :string, constraints: [predicate: {:type?, :string}] } @@ -37,11 +37,85 @@ defmodule Drops.Types.Map do """ - @type t :: %__MODULE__{} - + alias Drops.Predicates alias Drops.Types.Map.Key - defstruct [:primitive, :constraints, :keys, :atomize] + use Drops.Type do + deftype(:map, keys: [], atomize: false) + + def new(keys, opts) when is_list(keys) do + atomize = opts[:atomize] || false + struct(__MODULE__, keys: keys, atomize: atomize) + end + end + + defimpl Drops.Type.Validator, for: Map do + def validate(%{atomize: true, keys: keys} = type, data) do + case apply_predicates(Map.atomize(data, keys), type.constraints) do + {:ok, result} -> + results = Enum.map(type.keys, &Key.validate(&1, result)) |> List.flatten() + errors = Enum.reject(results, &is_ok/1) + + if Enum.empty?(errors), + do: {:ok, {:map, results}}, + else: {:error, {:map, results}} + + {:error, errors} -> + {:error, errors} + end + end + + def validate(type, data) do + case apply_predicates(data, type.constraints) do + {:ok, result} -> + results = Enum.map(type.keys, &Key.validate(&1, result)) |> List.flatten() + errors = Enum.reject(results, &is_ok/1) + + if Enum.empty?(errors), + do: {:ok, {:map, results}}, + else: {:error, {:map, results}} + + {:error, {value, meta}} -> + {:error, Keyword.merge([input: value], meta)} + + {:error, errors} -> + {:error, errors} + end + end + + defp apply_predicates(value, {:and, predicates}) do + apply_predicates(value, predicates) + end + + defp apply_predicates(value, predicates) do + Enum.reduce(predicates, {:ok, value}, &apply_predicate(&1, &2)) + end + + defp apply_predicate({:predicate, {name, args}}, {:ok, value}) do + apply_args = + case args do + [arg] -> [arg, value] + [] -> [value] + arg -> [arg, value] + end + + if apply(Predicates, name, apply_args) do + {:ok, value} + else + {:error, {value, predicate: name, args: apply_args}} + end + end + + defp apply_predicate(_, {:error, _} = error) do + error + end + + defp is_ok(results) when is_list(results), do: Enum.all?(results, &is_ok/1) + defp is_ok(:ok), do: true + defp is_ok({:ok, _}), do: true + defp is_ok(:error), do: false + defp is_ok({:error, _}), do: false + end def atomize(data, keys, initial \\ %{}) do Enum.reduce(keys, initial, fn %{path: path} = key, acc -> diff --git a/lib/drops/types/map/key.ex b/lib/drops/types/map/key.ex index f04a6b1..134232f 100644 --- a/lib/drops/types/map/key.ex +++ b/lib/drops/types/map/key.ex @@ -2,9 +2,26 @@ defmodule Drops.Types.Map.Key do @moduledoc false alias __MODULE__ + alias Drops.Type.Validator defstruct [:path, :presence, :type] + defimpl Drops.Type.Validator, for: Key do + def validate(type, data), do: Key.validate(type, data) + end + + def validate(%Key{presence: presence, path: path} = key, data) do + if present?(data, key) do + result = Validator.validate(key.type, get_in(data, path)) + nest_result(result, path) + else + case presence do + :required -> {:error, {path, {data, [predicate: :has_key?, args: []]}}} + :optional -> :ok + end + end + end + def stringify(key) do %Key{path: Enum.map(key.path, &to_string/1), presence: key.presence, type: key.type} end @@ -24,4 +41,28 @@ defmodule Drops.Types.Map.Key do def present?(map, [key | tail]) do Map.has_key?(map, key) and present?(map[key], tail) end + + defp nest_result({:error, {:or, {left, right}}}, root) do + {:error, {:or, {nest_result(left, root), nest_result(right, root)}}} + end + + defp nest_result({:error, {:list, results}}, root) when is_list(results) do + {:error, {root, {:list, Enum.with_index(results, &nest_result(&1, root ++ [&2]))}}} + end + + defp nest_result({:error, {:list, result}}, root) when is_tuple(result) do + {:error, {root, result}} + end + + defp nest_result(results, root) when is_list(results) do + Enum.map(results, &nest_result(&1, root)) + end + + defp nest_result({outcome, {path, result}}, root) when is_list(path) do + {outcome, {root ++ path, result}} + end + + defp nest_result({outcome, value}, root) do + {outcome, {root, value}} + end end diff --git a/lib/drops/types/primitive.ex b/lib/drops/types/primitive.ex new file mode 100644 index 0000000..20c6f7d --- /dev/null +++ b/lib/drops/types/primitive.ex @@ -0,0 +1,21 @@ +defmodule Drops.Types.Primitive do + @moduledoc ~S""" + Drops.Types.Primitive is a struct that represents a primitive type with optional constraints. + + ## Examples + + iex> Drops.Type.Compiler.visit({:type, {:string, []}}, []) + %Drops.Types.Primitive{ + primitive: :string, + constraints: [predicate: {:type?, :string}] + } + + iex> Drops.Type.Compiler.visit({:type, {:string, [:filled?]}}, []) + %Drops.Types.Primitive{ + primitive: :string, + constraints: {:and, [predicate: {:type?, :string}, predicate: {:filled?, []}]} + } + + """ + use Drops.Type +end diff --git a/lib/drops/types/sum.ex b/lib/drops/types/sum.ex index 129509c..13e4d5a 100644 --- a/lib/drops/types/sum.ex +++ b/lib/drops/types/sum.ex @@ -4,13 +4,13 @@ defmodule Drops.Types.Sum do ## Examples - iex> Drops.Types.from_spec([{:type, {:string, []}}, {:type, {:integer, []}}], []) + iex> Drops.Type.Compiler.visit([{:type, {:string, []}}, {:type, {:integer, []}}], []) %Drops.Types.Sum{ - left: %Drops.Types.Type{ + left: %Drops.Types.Primitive{ primitive: :string, constraints: [predicate: {:type?, :string}] }, - right: %Drops.Types.Type{ + right: %Drops.Types.Primitive{ primitive: :integer, constraints: [predicate: {:type?, :integer}] }, @@ -18,5 +18,29 @@ defmodule Drops.Types.Sum do } """ - defstruct [:left, :right, :opts] + use Drops.Type do + deftype([:left, :right, :opts]) + + def new(left, right) when is_struct(left) and is_struct(right) do + struct(__MODULE__, left: left, right: right) + end + end + + defimpl Drops.Type.Validator, for: Sum do + def validate(%{left: left, right: right}, input) do + case Drops.Type.Validator.validate(left, input) do + {:ok, value} -> + {:ok, value} + + {:error, _} = left_error -> + case Drops.Type.Validator.validate(right, input) do + {:ok, value} -> + {:ok, value} + + {:error, _} = right_error -> + {:error, {:or, {left_error, right_error}}} + end + end + end + end end diff --git a/lib/drops/types/type.ex b/lib/drops/types/type.ex deleted file mode 100644 index 2ceaa37..0000000 --- a/lib/drops/types/type.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Drops.Types.Type do - @moduledoc ~S""" - Drops.Types.Type is a struct that represents a primitive type with optional constraints. - - ## Examples - - iex> Drops.Types.from_spec({:type, {:string, []}}, []) - %Drops.Types.Type{ - primitive: :string, - constraints: [predicate: {:type?, :string}] - } - - iex> Drops.Types.from_spec({:type, {:string, [:filled?]}}, []) - %Drops.Types.Type{ - primitive: :string, - constraints: {:and, [predicate: {:type?, :string}, predicate: {:filled?, []}]} - } - - """ - defstruct [:primitive, :constraints] -end diff --git a/lib/drops/validator.ex b/lib/drops/validator.ex index 442012c..85054dd 100644 --- a/lib/drops/validator.ex +++ b/lib/drops/validator.ex @@ -6,96 +6,31 @@ defmodule Drops.Validator do alias Drops.Types alias Drops.Types.Map.Key - def validate(value, %Types.Cast{} = type, path: path) do - %{input_type: input_type, output_type: output_type, opts: cast_opts} = type + def validate(data, %Types.Map{} = type, path: path) do + case Drops.Type.Validator.validate(type, data) do + {:ok, value} -> + {:ok, {path, value}} - caster = cast_opts[:caster] || Casters - - case validate(value, input_type, path: path) do - {:ok, _} -> - casted_value = - apply( - caster, - :cast, - [input_type.primitive, output_type.primitive, value] ++ cast_opts - ) - - validate(casted_value, output_type, path: path) - - {:error, _} = error -> - {:error, {:cast, error}} + {:error, {value, meta}} -> + {:error, {path, {value, meta}}} end end - def validate(input, %Types.Map{} = type) do - validate(input, type.constraints, path: []) - end - def validate(data, keys) when is_list(keys) do - Enum.map(keys, &validate(data, &1)) |> List.flatten() - end - - def validate(data, %Key{presence: :required, path: path} = key) do - if Key.present?(data, key) do - validate(get_in(data, path), key.type, path: path) - else - {:error, {[], :has_key?, path}} - end - end - - def validate(data, %Key{presence: :optional, path: path} = key) do - if Key.present?(data, key) do - validate(get_in(data, path), key.type, path: path) - else - :ok - end - end - - def validate(value, %Types.Type{constraints: constraints}, path: path) do - validate(value, constraints, path: path) - end - - def validate(value, predicates, path: path) when is_list(predicates) do - apply_predicates(value, predicates, path: path) - end - - def validate(value, %Types.Sum{} = type, path: path) do - case validate(value, type.left, path: path) do - {:ok, _} = success -> - success - - {:error, _} = left_error -> - case validate(value, type.right, path: path) do - {:ok, _} = success -> - success - - {:error, _} = right_error -> - {:error, [{:or, {left_error, right_error}}]} - end - end - end - - def validate(value, %Types.List{member_type: member_type} = type, path: path) do - case validate(value, type.constraints, path: path) do - {:ok, {_, members}} -> - result = - List.flatten( - Enum.with_index(members, &validate(&1, member_type, path: path ++ [&2])) - ) - - errors = Enum.reject(result, &is_ok/1) - - if Enum.empty?(errors), do: {:ok, {path, result}}, else: {:error, errors} - - error -> - error - end + Enum.map(keys, &Drops.Type.Validator.validate(&1, data)) |> List.flatten() end def validate(value, {:and, predicates}, path: path) do validate(value, predicates, path: path) end + def validate(value, %{primitive: primitive, constraints: constraints} = type, + path: path + ) + when primitive != :map do + apply_predicates(value, constraints, path: path) + end + defp apply_predicates(value, {:and, predicates}, path: path) do apply_predicates(value, predicates, path: path) end @@ -123,6 +58,7 @@ defmodule Drops.Validator do error end + defp is_ok(results) when is_list(results), do: Enum.all?(results, &is_ok/1) defp is_ok(:ok), do: true defp is_ok({:ok, _}), do: true defp is_ok(:error), do: false diff --git a/lib/drops/validator/messages/backend.ex b/lib/drops/validator/messages/backend.ex index 4296a9c..2155f7b 100644 --- a/lib/drops/validator/messages/backend.ex +++ b/lib/drops/validator/messages/backend.ex @@ -31,12 +31,12 @@ defmodule Drops.Validator.Messages.Backend do %Drops.Validator.Messages.Error.Type{ path: [:email], text: "312 received but it must be a string", - meta: %{args: [:string, 312], predicate: :type?} + meta: [predicate: :type?, args: [:string, 312]] }, %Drops.Validator.Messages.Error.Type{ path: [:name], text: "cannot be empty", - meta: %{args: [""], predicate: :filled?} + meta: [predicate: :filled?, args: [""]] } ] } @@ -51,26 +51,26 @@ defmodule Drops.Validator.Messages.Backend do alias Drops.Validator.Messages.Error - def errors(results) when is_list(results) do - Enum.map(results, &error/1) - end - def errors(results) when is_tuple(results) do [error(results)] end + def errors(results) when is_list(results) do + Enum.map(results, &error/1) |> List.flatten() + end + defp error(text) when is_binary(text) do %Error.Rule{text: text} end - defp error({path, text}) when is_list(path) do + defp error({path, text}) when is_list(path) and is_binary(text) do %Error.Rule{text: text, path: path} end defp error(%{path: path} = error), do: error defp error(%Error.Sum{} = error), do: error - defp error({:error, {path, :has_key?, [value]}}) do + defp error({:error, {path, {input, [predicate: :has_key?, args: [value]] = meta}}}) do %Error.Key{ path: path ++ [value], text: text(:has_key?, value), @@ -81,38 +81,88 @@ defmodule Drops.Validator.Messages.Backend do } end - defp error({:error, {path, predicate, [value, input] = args}}) do - %Error.Type{ - path: path, - text: text(predicate, value, input), - meta: %{ - predicate: predicate, - args: args - } - } + defp error( + {:error, + {path, {input, [predicate: predicate, args: [value, _] = args] = meta}}} + ) do + %Error.Type{path: path, text: text(predicate, value, input), meta: meta} end - defp error({:error, {path, predicate, [input] = args}}) do - %Error.Type{ - path: path, - text: text(predicate, input), - meta: %{ - predicate: predicate, - args: args - } + defp error({:error, {path, [input: input, predicate: predicate, args: [value, _] = args] = meta}}) do + %Error.Type{path: path, text: text(predicate, value, input), meta: meta} + end + + defp error({:error, {path, {input, [predicate: predicate, args: _] = meta}}}) do + %Error.Type{path: path, text: text(predicate, input), meta: meta} + end + + defp error({:error, {path, {:map, results}}}) do + Enum.map(results, &error/1) + |> List.flatten() + |> Enum.reject(&is_nil/1) + |> Enum.map(&nest(&1, path)) + end + + defp error({:error, {:map, results}}) when is_list(results) do + %Error.Set{ + errors: Enum.reject(Enum.map(results, &error/1), &is_nil/1) } end + defp error({:error, {value, [predicate: predicate, args: _] = meta}}) do + %Error.Type{text: text(predicate, value), meta: meta} + end + + defp error({:error, {path, {:list, results}}}) when is_list(results) do + errors = Enum.map(results, &error/1) |> Enum.reject(&is_nil/1) + if Enum.empty?(errors), do: nil, else: %Error.Set{errors: errors} + end + + defp error({:error, {:list, results}}) when is_list(results) do + errors = + Enum.with_index(results, fn + {:error, _} = result, index -> nest(error(result), [index]) + {:ok, _}, _ -> nil + end) + |> Enum.reject(&is_nil/1) + + if Enum.empty?(errors), do: nil, else: %Error.Set{errors: errors} + end + + defp error(results) when is_list(results) do + errors = Enum.map(results, &error/1) |> Enum.reject(&is_nil/1) + if Enum.empty?(errors), do: nil, else: %Error.Set{errors: errors} + end + defp error({:error, results}) when is_list(results) do %Error.Set{errors: Enum.map(results, &error/1)} end - defp error({:or, {left, right}}) do + defp error({:error, text}) when is_atom(text) or is_binary(text) do + %Error.Rule{text: text} + end + + defp error({:error, {path, text}}) when is_atom(text) or is_binary(text) do + %Error.Rule{path: path, text: text} + end + + defp error({:error, {:or, {left, right}}}) do %Error.Sum{left: error(left), right: error(right)} end - defp error({:cast, error}) do - %Error.Caster{error: error(error)} + defp error({:error, {path, {:or, {left, right}}}}) do + nest(error({:error, {:or, {left, right}}}), path) + end + + defp error({:error, {path, {:cast, error}}}) do + %Error.Caster{error: error({:error, {path, error}})} + end + + defp error(:ok), do: nil + defp error({:ok, _}), do: nil + + defp nest(error, path) do + Error.Conversions.nest(error, path) end end end diff --git a/lib/drops/validator/messages/error.ex b/lib/drops/validator/messages/error.ex index 1e0b460..4f460aa 100644 --- a/lib/drops/validator/messages/error.ex +++ b/lib/drops/validator/messages/error.ex @@ -32,6 +32,12 @@ defmodule Drops.Validator.Messages.Error do end end + defimpl Error.Conversions, for: [List] do + def nest(errors, root) do + Enum.map(errors, &Error.Conversions.nest(&1, root)) + end + end + defimpl Error.Conversions, for: [Error.Type, Error.Key] do def nest(%{path: path} = error, root) do Map.merge(error, %{path: root ++ path}) @@ -45,6 +51,18 @@ defmodule Drops.Validator.Messages.Error do defstruct [:left, :right] defimpl String.Chars, for: Sum do + def to_string(%Error.Sum{left: left, right: right}) when is_list(left) and is_list(right) do + "#{Enum.map(left, &Kernel.to_string/1)} or #{Enum.map(right, &Kernel.to_string/1)}" + end + + def to_string(%Error.Sum{left: left, right: right}) when is_list(left) do + "#{Enum.map(left, &Kernel.to_string/1)} or #{right}" + end + + def to_string(%Error.Sum{left: left, right: right}) when is_list(right) do + "#{left} or #{Enum.map(right, &Kernel.to_string/1)}" + end + def to_string(%Error.Sum{left: left, right: right}) do "#{left} or #{right}" end @@ -68,7 +86,11 @@ defmodule Drops.Validator.Messages.Error do defimpl String.Chars, for: Error.Set do def to_string(%Error.Set{errors: errors}) do - Enum.map(errors, &Kernel.to_string/1) |> Enum.join(" and ") + Enum.map(errors, fn e -> + if is_list(e), do: Enum.map(e, &Kernel.to_string/1), else: Kernel.to_string(e) + end) + |> List.flatten() + |> Enum.join(" and ") end end diff --git a/mix.exs b/mix.exs index f42bb2a..641bede 100644 --- a/mix.exs +++ b/mix.exs @@ -20,7 +20,8 @@ defmodule Drops.MixProject do links: %{"GitHub" => @source_url}, package: package(), docs: docs(), - source_url: @source_url + source_url: @source_url, + consolidate_protocols: Mix.env() != :test ] end @@ -61,11 +62,11 @@ defmodule Drops.MixProject do ], Types: [ Drops.Types, - Drops.Types.Type, + Drops.Types.Primitive, Drops.Types.List, Drops.Types.Map, Drops.Types.Map.Key, - Drops.Types.Map.DSL, + Drops.Type.DSL, Drops.Types.Sum, Drops.Types.Cast ] diff --git a/test/contract/contract_test.exs b/test/contract/contract_test.exs index 671db0e..2ef5456 100644 --- a/test/contract/contract_test.exs +++ b/test/contract/contract_test.exs @@ -2,4 +2,19 @@ defmodule Drops.ContractTest do use Drops.ContractCase doctest Drops.Contract + + describe "conform/1" do + contract do + schema do + %{ + required(:name) => string(:filled?), + required(:email) => string(:filled?) + } + end + end + + test "returns errors when the input is not a map", %{contract: contract} do + assert_errors(["must be a map"], contract.conform("not a map")) + end + end end diff --git a/test/contract/messages_test.exs b/test/contract/messages_test.exs index c5c539d..6d5e892 100644 --- a/test/contract/messages_test.exs +++ b/test/contract/messages_test.exs @@ -18,7 +18,7 @@ defmodule Drops.Validator.MessagesTest do contract.conform(%{name: "Jane Doe"}) assert path == [:age] - assert meta == %{predicate: :has_key?, args: [:age]} + assert meta == [predicate: :has_key?, args: []] assert to_string(error) == "age key must be present" end end @@ -40,7 +40,7 @@ defmodule Drops.Validator.MessagesTest do contract.conform(%{name: "Jane Doe", age: "twenty"}) assert path == [:age] - assert meta == %{predicate: :type?, args: [:integer, "twenty"]} + assert meta == [predicate: :type?, args: [:integer, "twenty"]] assert to_string(error) == "age must be an integer" end @@ -49,7 +49,7 @@ defmodule Drops.Validator.MessagesTest do contract.conform(%{name: "", age: 21}) assert path == [:name] - assert meta == %{predicate: :filled?, args: [""]} + assert meta == [predicate: :filled?, args: [""]] assert to_string(error) == "name must be filled" end @@ -58,7 +58,7 @@ defmodule Drops.Validator.MessagesTest do contract.conform(%{name: "Jane", age: 12}) assert path == [:age] - assert meta == %{predicate: :gt?, args: [18, 12]} + assert meta == [predicate: :gt?, args: [18, 12]] assert to_string(error) == "age must be greater than 18" end @@ -67,7 +67,7 @@ defmodule Drops.Validator.MessagesTest do contract.conform(%{name: "Jane", age: 19, role: "oops"}) assert path == [:role] - assert meta == %{predicate: :in?, args: [["admin", "user"], "oops"]} + assert meta == [predicate: :in?, args: [["admin", "user"], "oops"]] assert to_string(error) == "role must be one of: admin, user" end @@ -76,10 +76,10 @@ defmodule Drops.Validator.MessagesTest do contract.conform(%{birthday: "oops"}) assert left_error.path == [:birthday] - assert left_error.meta == %{predicate: :type?, args: [nil, "oops"]} + assert left_error.meta == [predicate: :type?, args: [nil, "oops"]] assert right_error.path == [:birthday] - assert right_error.meta == %{predicate: :type?, args: [:date, "oops"]} + assert right_error.meta == [predicate: :type?, args: [:date, "oops"]] assert to_string(error) == "birthday must be nil or birthday must be a date" end @@ -103,16 +103,16 @@ defmodule Drops.Validator.MessagesTest do contract.conform(%{user: %{age: "twenty"}}) assert path == [:user, :age] - assert meta == %{predicate: :type?, args: [:integer, "twenty"]} + assert meta == [predicate: :type?, args: [:integer, "twenty"]] assert to_string(error) == "user.age must be an integer" end test "returns errors from a list type", %{contract: contract} do - assert {:error, [error = %{path: path, meta: meta}]} = + assert {:error, [%{errors: [error = %{path: path, meta: meta}]}]} = contract.conform(%{user: %{roles: ["admin", 312, "moderator"]}}) assert path == [:user, :roles, 1] - assert meta == %{predicate: :type?, args: [:string, 312]} + assert meta == [predicate: :type?, args: [:string, 312]] assert to_string(error) == "user.roles.1 must be a string" end end diff --git a/test/contract/type_test.exs b/test/contract/type_test.exs index a6f5d5c..5e6da15 100644 --- a/test/contract/type_test.exs +++ b/test/contract/type_test.exs @@ -75,12 +75,14 @@ defmodule Drops.Contract.TypeTest do assert {:ok, %{test: %{a: 1, b: 2}}} = contract.conform(%{test: %{a: 1, b: 2}}) end - test "returns error with invalid data", %{contract: contract} do + test "returns error from left predicates", %{contract: contract} do assert_errors( ["test must be filled or test must be a map"], contract.conform(%{test: []}) ) + end + test "returns errors from right predicates", %{contract: contract} do assert_errors( ["test must be a list or test must be filled"], contract.conform(%{test: %{}}) diff --git a/test/contract/types/custom_test.exs b/test/contract/types/custom_test.exs new file mode 100644 index 0000000..3dc28ab --- /dev/null +++ b/test/contract/types/custom_test.exs @@ -0,0 +1,44 @@ +defmodule Drops.Contract.Types.CustomTest do + use Drops.ContractCase + + describe "using a custom primitive type" do + defmodule Email do + use Drops.Type, string() + end + + contract do + schema do + %{required(:test) => Email} + end + end + + test "returns success with a valid input", %{contract: contract} do + assert {:ok, %{test: "Hello World"}} = contract.conform(%{test: "Hello World"}) + end + + test "returns errors with invalid input", %{contract: contract} do + assert_errors(["test must be a string"], contract.conform(%{test: 1})) + end + end + + describe "using a custom type with extra predicates" do + defmodule FilledString do + use Drops.Type, string(:filled?) + end + + contract do + schema do + %{required(:test) => FilledString} + end + end + + test "returns success with a valid input", %{contract: contract} do + assert {:ok, %{test: "Hello World"}} = contract.conform(%{test: "Hello World"}) + end + + test "returns errors with invalid input", %{contract: contract} do + assert_errors(["test must be a string"], contract.conform(%{test: 1})) + assert_errors(["test must be filled"], contract.conform(%{test: ""})) + end + end +end diff --git a/test/contract/validator_test.exs b/test/contract/validator_test.exs index f5f3aa6..10ae03e 100644 --- a/test/contract/validator_test.exs +++ b/test/contract/validator_test.exs @@ -4,13 +4,17 @@ defmodule Drops.ValidatorTest do describe "validate/3" do test "validates a string" do - assert validate("foo", Types.from_spec({:type, {:string, []}}, []), path: []) == + assert validate("foo", Types.Primitive.new(:string), path: []) == {:ok, {[], "foo"}} end test "validates an integer with constraints" do - assert validate(11, Types.from_spec({:type, {:integer, [:odd?]}}, []), path: []) == - {:ok, {[], 11}} + type = Types.Primitive.new(:integer, [:odd?]) + + assert validate(11, type, path: []) == {:ok, {[], 11}} + + assert validate("foo", type, path: []) == {:error, {[], :type?, [:integer, "foo"]}} + assert validate(12, type, path: []) == {:error, {[], :odd?, [12]}} end end end