diff --git a/lib/norm.ex b/lib/norm.ex index 95b01f5..f0b7f60 100644 --- a/lib/norm.ex +++ b/lib/norm.ex @@ -150,6 +150,10 @@ defmodule Norm do Spec.build(predicate) end + defmacro spec(predicate, message) do + Spec.build(predicate, message) + end + @doc ~S""" Creates a re-usable schema. Schema's are open which means that all keys are optional and any non-specified keys are passed through without being conformed. diff --git a/lib/norm/conformer.ex b/lib/norm/conformer.ex index f67d4b9..2c9ae4d 100644 --- a/lib/norm/conformer.ex +++ b/lib/norm/conformer.ex @@ -3,6 +3,21 @@ defmodule Norm.Conformer do # This module provides an api for conforming values and a protocol for # conformable types + def conform(specs, input) when is_list(specs) do + Enum.reduce(specs, {}, fn s, acc -> + case acc do + {} -> + Norm.Conformer.Conformable.conform(s, input, []) + + {:ok, _} -> + Norm.Conformer.Conformable.conform(s, input, []) + + {:error, e} -> + {:error, e} + end + end) + end + def conform(spec, input) do Norm.Conformer.Conformable.conform(spec, input, []) end @@ -15,8 +30,12 @@ defmodule Norm.Conformer do |> update_in([:error], &List.flatten(&1)) end - def error(path, input, msg) do - %{path: path, input: input, spec: msg} + def error(path, input, spec_msg) do + %{path: path, input: input, spec: spec_msg} + end + + def error(path, input, spec_msg, validation_msg) do + %{path: path, input: input, spec: spec_msg, message: validation_msg} end def error_to_msg(%{path: path, input: input, spec: msg}) do diff --git a/lib/norm/core/spec.ex b/lib/norm/core/spec.ex index b988622..6a6c54c 100644 --- a/lib/norm/core/spec.ex +++ b/lib/norm/core/spec.ex @@ -9,7 +9,15 @@ defmodule Norm.Core.Spec do Or } - defstruct predicate: nil, generator: nil, f: nil + defstruct predicate: nil, generator: nil, f: nil, message: nil + + def build(quoted, message) do + built_spec = build(quoted) + + quote do + %Spec{unquote(built_spec) | message: unquote(message)} + end + end def build({:or, _, [left, right]}) do l = build(left) @@ -131,6 +139,19 @@ defmodule Norm.Core.Spec do end defimpl Norm.Conformer.Conformable do + def conform(%{f: f, predicate: pred, message: msg}, input, path) do + case f.(input) do + true -> + {:ok, input} + + false -> + {:error, [Norm.Conformer.error(path, input, pred, msg)]} + + _ -> + raise ArgumentError, "Predicates must return a boolean value" + end + end + def conform(%{f: f, predicate: pred}, input, path) do case f.(input) do true -> diff --git a/test/norm/core/spec_test.exs b/test/norm/core/spec_test.exs index c70b553..ea83923 100644 --- a/test/norm/core/spec_test.exs +++ b/test/norm/core/spec_test.exs @@ -7,6 +7,40 @@ defmodule Norm.Core.SpecTest do def match?(x, given), do: x == given end + describe "WIP - spec/2" do + test "WIP - can include validation messages" do + is_binary_spec = spec(is_binary(), "is not binary") + + {:error, [error]} = conform(1, is_binary_spec) + + assert error.message == "is not binary" + end + + test "WIP - can chain specs and their validation messages" do + # NOTE: This isn't really a concern of the Spec module but I want to keep things centralized for this POC + is_string_spec = [ + spec(is_binary(), "is not binary"), + spec(&(String.length(&1) > 1), "must have more than 2 characters") + ] + + {:error, [error]} = conform("", is_string_spec) + + assert error.message == "must have more than 2 characters" + end + + test "WIP - chained specs fail fast to not invoke following specs" do + # NOTE: This isn't really a concern of the Spec module but I want to keep things centralized for this POC + really_bad_spec = [ + spec(&(&1 && false), "I didn't like that"), + spec(&(&1 && raise ""), "I really won't like this") + ] + + {:error, [error]} = conform("", really_bad_spec) + + assert error.message == "I didn't like that" + end + end + describe "spec/1" do test "can compose specs with 'and'" do hex = spec(is_binary() and (&String.starts_with?(&1, "#")))