Skip to content

Commit

Permalink
Add error message backends
Browse files Browse the repository at this point in the history
  • Loading branch information
solnic committed Oct 18, 2023
1 parent 85ff9cc commit 3ca819c
Show file tree
Hide file tree
Showing 4 changed files with 236 additions and 1 deletion.
22 changes: 22 additions & 0 deletions examples/contract/messages-01.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
defmodule MyBackend do
use Drops.Contract.Messages.Backend

def text(:type?, type, input) do
"#{inspect(input)} received but it must be a #{type}"
end

def text(:filled?, _input) do
"cannot be empty"
end
end
defmodule UserContract do
use Drops.Contract, message_backend: MyBackend

schema do
%{
required(:name) => string(:filled?),
required(:email) => string(:filled?)
}
end
end
UserContract.conform(%{name: "", email: 312}) |> UserContract.errors()
11 changes: 10 additions & 1 deletion lib/drops/contract.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,21 @@ defmodule Drops.Contract do
@callback conform(data :: map(), schema :: Types.Map, keyword()) ::
{:ok, map()} | {:error, list()}

defmacro __using__(_opts) do
defmacro __using__(opts) do
quote do
use Drops.Validator

alias Drops.Types
alias Drops.Contract.Messages

import Drops.Contract
import Drops.Contract.Runtime
import Drops.Types.Map.DSL

@behaviour Drops.Contract

@message_backend unquote(opts[:message_backend]) || Messages.DefaultBackend

Module.register_attribute(__MODULE__, :rules, accumulate: true)

@before_compile Drops.Contract.Runtime
Expand Down Expand Up @@ -103,6 +106,12 @@ defmodule Drops.Contract do
end
end

def errors({:error, errors}) do
@message_backend.errors(errors)
end

def errors({:ok, _}), do: []

def validate(data, keys) when is_list(keys) do
Enum.map(keys, &validate(data, &1)) |> List.flatten()
end
Expand Down
147 changes: 147 additions & 0 deletions lib/drops/contract/messages.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
defmodule Drops.Contract.Messages do
defmodule Error do
alias __MODULE__

defstruct [:path, :text, :opts]

defimpl String.Chars, for: Error do
def to_string(%Error{path: path, text: text}) do
"#{Enum.join(path, ".")} #{text}"
end
end
end

@moduledoc ~S"""
Messages Backends are used to generate error messages from error results.
## Examples
iex> defmodule MyBackend do
...> use Drops.Contract.Messages.Backend
...>
...> def text(:type?, type, input) do
...> "#{inspect(input)} received but it must be a #{type}"
...> end
...>
...> def text(:filled?, _input) do
...> "cannot be empty"
...> end
...> end
iex> defmodule UserContract do
...> use Drops.Contract, message_backend: MyBackend
...>
...> schema do
...> %{
...> required(:name) => string(:filled?),
...> required(:email) => string(:filled?)
...> }
...> end
...> end
iex> UserContract.conform(%{name: "", email: 312}) |> UserContract.errors()
[
%Drops.Contract.Messages.Error{
path: [:email],
text: "312 received but it must be a string",
opts: %{args: [:string, 312], predicate: :type?}
},
%Drops.Contract.Messages.Error{
path: [:name],
text: "cannot be empty",
opts: %{args: [""], predicate: :filled?}
}
]
"""
defmodule Backend do
@callback text(atom(), any()) :: String.t()
@callback text(atom(), any(), any()) :: String.t()

defmacro __using__(_opts) do
quote do
@behaviour Drops.Contract.Messages.Backend

def errors(results) do
Enum.map(results, &error/1)
end

defp error({:error, {path, predicate, [value, input] = args}}) do
%Error{
path: path,
text: text(predicate, value, input),
opts: %{
predicate: predicate,
args: args
}
}
end

defp error({:error, {path, predicate, [input] = args}}) do
%Error{
path: path,
text: text(predicate, input),
opts: %{
predicate: predicate,
args: args
}
}
end
end
end
end

defmodule DefaultBackend do
use Backend

@text_mapping %{
type?: %{
integer: "must be an integer",
float: "must be a float",
boolean: "must be boolean",
list: "must be a list",
map: "must be a map",
string: "must be a string",
atom: "must be an atom",
date: "must be a date",
date_time: "must be a date time",
time: "must be a time"
},
filled?: "must be filled",
empty?: "must be empty",
eql?: "must be equal to %input%",
not_eql?: "must not be equal to %input%",
lt?: "must be less than %input%",
gt?: "must be greater than %input%",
lteq?: "must be less than or equal to %input%",
gteq?: "must be greater than or equal to %input%",
min_size?: "size cannot be less than %input%",
max_size?: "size cannot be greater than %input%",
size?: "size must be %input%",
even?: "must be even",
odd?: "must be odd",
match?: "must match %input%",
includes?: "must include %input%",
excludes?: "must exclude %input%",
in?: "must be one of: %input%",
}

@impl true
def text(predicate, _input) do
@text_mapping[predicate]
end

@impl true
def text(:type?, type, _input) do
@text_mapping[:type?][type]
end

@impl true
def text(:in?, values, _input) do
String.replace(@text_mapping[:in?], "%input%", Enum.join(values, ", "))
end

@impl true
def text(predicate, value, _input) do
String.replace(@text_mapping[predicate], "%input%", to_string(value))
end
end
end
57 changes: 57 additions & 0 deletions test/contract/messages_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
defmodule Drops.Contract.MessagesTest do
use Drops.ContractCase

doctest Drops.Contract.Messages

describe "errors/1" do
contract do
schema do
%{
required(:name) => string(:filled?),
required(:age) => integer(gt?: 18),
optional(:role) => string(in?: ["admin", "user"])
}
end
end

test "returns errors from a type? predicate", %{contract: contract} do
result = contract.conform(%{name: "Jane Doe", age: "twenty"})

assert [error = %{path: path, opts: opts}] = contract.errors(result)

assert path == [:age]
assert opts == %{predicate: :type?, args: [:integer, "twenty"]}
assert to_string(error) == "age must be an integer"
end

test "returns errors from a predicate with no args", %{contract: contract} do
result = contract.conform(%{name: "", age: 21})

assert [error = %{path: path, opts: opts}] = contract.errors(result)

assert path == [:name]
assert opts == %{predicate: :filled?, args: [""]}
assert to_string(error) == "name must be filled"
end

test "returns errors from a predicate with args", %{contract: contract} do
result = contract.conform(%{name: "Jane", age: 12})

assert [error = %{path: path, opts: opts}] = contract.errors(result)

assert path == [:age]
assert opts == %{predicate: :gt?, args: [18, 12]}
assert to_string(error) == "age must be greater than 18"
end

test "returns errors from in? with a list of valid values", %{contract: contract} do
result = contract.conform(%{name: "Jane", age: 19, role: "oops"})

assert [error = %{path: path, opts: opts}] = contract.errors(result)

assert path == [:role]
assert opts == %{predicate: :in?, args: [["admin", "user"], "oops"]}
assert to_string(error) == "role must be one of: admin, user"
end
end
end

0 comments on commit 3ca819c

Please sign in to comment.