Current section

Files

Jump to
schematic lib schematic.ex
Raw

lib/schematic.ex

defmodule Schematic do
defstruct [:unify, :kind, :message]
@opaque t :: %__MODULE__{
unify: (term(), :up | :down -> {:ok, term()} | {:error, String.t() | [String.t()]}),
kind: String.t(),
message: String.t() | nil
}
defmodule OptionalKey do
@enforce_keys [:key]
defstruct [:key]
end
@spec any() :: t()
def any() do
%Schematic{kind: "any", unify: fn x, _dir -> {:ok, x} end}
end
@spec null() :: t()
def null() do
%Schematic{
kind: "null",
message: "null",
unify: fn
nil, _dir -> {:ok, nil}
_input, _dir -> {:error, "expected null"}
end
}
end
@spec nullable(t()) :: t()
def nullable(schematic) do
oneof([null(), schematic])
end
@spec bool(boolean() | nil) :: t()
def bool(literal \\ nil) do
message =
if is_boolean(literal) do
"#{inspect(literal)}"
else
"a boolean"
end
%Schematic{
kind: "boolean",
message: message,
unify: fn input, _dir ->
# FIXME: this is ugly
cond do
is_boolean(literal) ->
if is_boolean(input) && input == literal do
{:ok, input}
else
{:error, ~s|expected #{message}|}
end
is_boolean(input) ->
{:ok, input}
true ->
{:error, "expected #{message}"}
end
end
}
end
@spec str(String.t() | nil) :: t()
def str(literal \\ nil) do
message =
if literal do
"the literal string #{inspect(literal)}"
else
"a string"
end
%Schematic{
kind: "string",
message: message,
unify: fn input, _dir ->
# FIXME: this is ugly
cond do
is_binary(literal) ->
if is_binary(input) && input == literal do
{:ok, input}
else
{:error, ~s|expected #{message}|}
end
is_binary(input) ->
{:ok, input}
true ->
{:error, "expected #{message}"}
end
end
}
end
@spec int(integer() | nil) :: t()
def int(literal \\ nil) do
message =
if literal do
"the literal integer #{inspect(literal)}"
else
"an integer"
end
%Schematic{
kind: "integer",
message: message,
unify: fn input, _dir ->
# FIXME: this is ugly
cond do
is_integer(literal) ->
if is_integer(input) && input == literal do
{:ok, input}
else
{:error, ~s|expected #{message}|}
end
is_integer(input) ->
{:ok, input}
true ->
{:error, "expected #{message}"}
end
end
}
end
@spec list() :: t()
def list() do
message = "a list"
%Schematic{
kind: "list",
message: message,
unify: fn input, _dir ->
if is_list(input) do
{:ok, input}
else
{:error, ~s|expected #{message}|}
end
end
}
end
@spec list(t()) :: t()
def list(schematic) do
message = "a list of #{schematic.message}"
%Schematic{
kind: "list",
message: message,
unify: fn input, dir ->
if is_list(input) do
Enum.reduce_while(input, {:ok, []}, fn el, {:ok, acc} ->
case schematic.unify.(el, dir) do
{:ok, output} ->
{:cont, {:ok, [output | acc]}}
{:error, _error} ->
{:halt, {:error, ~s|expected #{message}|}}
end
end)
|> then(fn
{:ok, result} ->
{:ok, Enum.reverse(result)}
error ->
error
end)
else
{:error, ~s|expected a list|}
end
end
}
end
@spec tuple([t()], Keyword.t()) :: t()
def tuple(schematics, opts \\ []) do
message = "a tuple of [#{Enum.map_join(schematics, ", ", & &1.message)}]"
from = Keyword.get(opts, :from, :tuple)
{condition, to_list} =
case from do
:list ->
{&is_list/1, &Function.identity/1}
:tuple ->
{&is_tuple/1, &Tuple.to_list/1}
end
%Schematic{
kind: "tuple",
message: message,
unify: fn input, dir ->
if condition.(input) do
input
|> to_list.()
|> Enum.with_index()
|> Enum.reduce_while({:ok, []}, fn {el, idx}, {:ok, acc} ->
case Enum.at(schematics, idx).unify.(el, dir) do
{:ok, output} ->
{:cont, {:ok, [output | acc]}}
{:error, _error} ->
{:halt, {:error, ~s|expected #{message}|}}
end
end)
|> then(fn
{:ok, result} ->
{:ok, result |> Enum.reverse() |> List.to_tuple()}
error ->
error
end)
else
{:error, ~s|expected a list|}
end
end
}
end
@spec map(map() | Keyword.t()) :: t()
def map(blueprint \\ %{})
def map(blueprint) when is_map(blueprint) do
%Schematic{
kind: "map",
message: "a map",
unify: fn input, dir ->
if is_map(input) do
bp_keys = Map.keys(blueprint)
Enum.reduce(
bp_keys,
[ok: %{}, errors: %{}],
fn bpk, [{:ok, acc}, {:errors, errors}] ->
schematic = blueprint[bpk]
key = with %OptionalKey{key: key} <- bpk, do: key
{from_key, to_key} = with key when not is_tuple(key) <- key, do: {key, key}
{from_key, to_key} =
case dir do
:to -> {from_key, to_key}
:from -> {to_key, from_key}
end
if not Map.has_key?(input, from_key) and match?(%OptionalKey{}, bpk) do
[{:ok, acc}, {:errors, errors}]
else
case schematic.unify.(input[from_key], dir) do
{:ok, output} ->
acc =
acc
|> Map.delete(from_key)
|> Map.put(to_key, output)
[{:ok, acc}, {:errors, errors}]
{:error, error} ->
[{:ok, acc}, {:errors, Map.put(errors, from_key, error)}]
end
end
end
)
|> then(fn
[ok: output, errors: e] when map_size(e) == 0 ->
{:ok, output}
[ok: _output, errors: errors] ->
{:error, errors}
end)
else
{:error, "expected a map"}
end
end
}
end
def map(opts) when is_list(opts) do
key_schematic = Keyword.get(opts, :keys, any())
value_schematic = Keyword.get(opts, :values, any())
%Schematic{
kind: "map",
message: "a map",
unify: fn input, dir ->
if is_map(input) do
Enum.reduce(
Map.keys(input),
[ok: %{}, errors: %{}],
fn input_key, [{:ok, acc}, {:errors, errors}] ->
case key_schematic.unify.(input_key, dir) do
{:ok, key_output} ->
case value_schematic.unify.(input[input_key], dir) do
{:ok, value_output} ->
[{:ok, Map.put(acc, key_output, value_output)}, {:errors, errors}]
{:error, error} ->
[{:ok, acc}, {:errors, Map.put(errors, input_key, error)}]
end
{:error, _error} ->
# NOTE: we pass just ignore keys which non conforming keys
[{:ok, acc}, {:errors, errors}]
end
end
)
|> then(fn
[ok: output, errors: e] when map_size(e) == 0 ->
{:ok, output}
[ok: _output, errors: errors] ->
{:error, errors}
end)
else
{:error, "expected a map"}
end
end
}
end
@spec schema(atom(), map()) :: t()
def schema(mod, schematic) do
schematic =
map(
Map.new(schematic, fn
{k, v} when is_atom(k) ->
{{to_string(k), k}, v}
kv ->
kv
end)
)
%Schematic{
kind: "map",
message: "a %#{String.replace(to_string(mod), "Elixir.", "")}{}",
unify: fn input, dir ->
case dir do
:to ->
with {:ok, output} <- schematic.unify.(input, :to) do
{:ok, struct(mod, output)}
end
:from ->
with {:ok, input} <- struct?(input, mod),
{:ok, output} <- schematic.unify.(Map.from_struct(input), :from) do
{:ok, output}
end
end
end
}
end
defp struct?(input, mod) when is_struct(input, mod) do
{:ok, input}
end
defp struct?(_input, mod) do
{:error, "expected a #{mod} struct"}
end
@spec raw((any() -> boolean()), [tuple()]) :: t()
def raw(function, opts \\ []) do
message = Keyword.get(opts, :message, "is invalid")
transformer = Keyword.get(opts, :transform, fn input, _dir -> input end)
%Schematic{
kind: "function",
message: message,
unify: fn input, dir ->
if convert_to_two_arity(function).(input, dir) do
{:ok, convert_to_two_arity(transformer).(input, dir)}
else
{:error, message}
end
end
}
end
defp convert_to_two_arity(f) when is_function(f, 1) do
fn a, _ -> f.(a) end
end
defp convert_to_two_arity(f) when is_function(f, 2) do
f
end
@spec all([t()]) :: t()
def all(schematics) when is_list(schematics) do
message = Enum.map(schematics, & &1.message)
%Schematic{
kind: "all",
message: message,
unify: fn input, dir ->
errors =
for schematic <- schematics,
{result, message} = schematic.unify.(input, dir),
result == :error do
message
end
if Enum.empty?(errors) do
{:ok, input}
else
{:error, errors}
end
end
}
end
@spec oneof([t()] | (any -> t())) :: t()
def oneof(schematics) when is_list(schematics) do
message = "either #{sentence_join(schematics, "or", & &1.message)}"
%Schematic{
kind: "oneof",
message: message,
unify: fn input, dir ->
inquiry =
Enum.find_value(schematics, fn schematic ->
with {:error, _} <- schematic.unify.(input, dir), do: false
end)
with nil <- inquiry, do: {:error, ~s|expected #{message}|}
end
}
end
def oneof(dispatch) when is_function(dispatch) do
%Schematic{
kind: "oneof",
unify: fn input, dir ->
with %Schematic{} = schematic <- dispatch.(input) do
schematic.unify.(input, dir)
end
end
}
end
defp sentence_join(items, joiner, mapper) do
length = length(items)
item_joiner = if length > 2, do: ", ", else: " "
Enum.map_join(Enum.with_index(items), item_joiner, fn {item, idx} ->
if idx == length - 1, do: joiner <> " " <> (mapper.(item) || ""), else: mapper.(item)
end)
end
@spec unify(t(), any()) :: any()
def unify(schematic, input) do
schematic.unify.(input, :to)
end
@spec dump(t(), any()) :: any()
def dump(schematic, input) do
schematic.unify.(input, :from)
end
@spec optional(any) :: %OptionalKey{key: any()}
def optional(key) do
%OptionalKey{key: key}
end
end