Current section

Files

Jump to
schematic lib schematic.ex
Raw

lib/schematic.ex

defmodule Schematic do
defstruct [:assimilate, :kind, :message]
defmodule OptionalKey do
@enforce_keys [:key]
defstruct [:key]
end
def any() do
%Schematic{kind: :any, message: "", assimilate: fn x -> {:ok, x} end}
end
def null() do
%Schematic{
kind: :null,
message: "null",
assimilate: fn
nil -> {:ok, nil}
_input -> {:error, "expected null"}
end
}
end
def bool(literal \\ nil) do
message =
if is_boolean(literal) do
"#{inspect(literal)}"
else
"a boolean"
end
%Schematic{
kind: "boolean",
message: message,
assimilate: fn input ->
# 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
def str(literal \\ nil) do
message =
if literal do
"the literal string #{inspect(literal)}"
else
"a string"
end
%Schematic{
kind: "string",
message: message,
assimilate: fn input ->
# 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
def int(literal \\ nil) do
message =
if literal do
"the literal integer #{inspect(literal)}"
else
"an integer"
end
%Schematic{
kind: "integer",
message: message,
assimilate: fn input ->
# 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
def list() do
message = "a list"
%Schematic{
kind: "list",
message: message,
assimilate: fn input ->
if is_list(input) do
{:ok, input}
else
{:error, ~s|expected #{message}|}
end
end
}
end
def list(schematic) do
message = "a list of #{schematic.message}"
%Schematic{
kind: "list",
message: message,
assimilate: fn input ->
if is_list(input) do
Enum.reduce_while(input, {:ok, []}, fn el, {:ok, acc} ->
case assimilate(schematic, el) 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
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,
assimilate: fn input ->
if condition.(input) do
input
|> to_list.()
|> Enum.with_index()
|> Enum.reduce_while({:ok, []}, fn {el, idx}, {:ok, acc} ->
case(assimilate(Enum.at(schematics, idx), el)) 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
def map(blueprint \\ %{})
def map(blueprint) when is_map(blueprint) do
%Schematic{
kind: "map",
message: "a map",
assimilate: fn input ->
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}
if not Map.has_key?(input, from_key) and match?(%OptionalKey{}, bpk) do
[{:ok, acc}, {:errors, errors}]
else
case assimilate(schematic, input[from_key]) 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",
assimilate: fn input ->
if is_map(input) do
Enum.reduce(
Map.keys(input),
[ok: %{}, errors: %{}],
fn input_key, [{:ok, acc}, {:errors, errors}] ->
case assimilate(key_schematic, input_key) do
{:ok, key_output} ->
case assimilate(value_schematic, input[input_key]) 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
def schema(mod, schematic) do
schematic =
Map.new(schematic, fn
{k, v} when is_atom(k) ->
{{to_string(k), k}, v}
kv ->
kv
end)
%Schematic{
kind: "map",
message: "a map",
assimilate: fn input ->
with {:ok, output} <- assimilate(map(schematic), input) do
{:ok, struct(mod, output)}
end
end
}
end
def raw(function, opts \\ []) do
message = Keyword.get(opts, :message, "is invalid")
transformer = Keyword.get(opts, :transform, &Function.identity/1)
%Schematic{
kind: "function",
message: message,
assimilate: fn input ->
if function.(input) do
{:ok, transformer.(input)}
else
{:error, message}
end
end
}
end
def all(schematics) when is_list(schematics) do
message = Enum.map(schematics, & &1.message)
%Schematic{
kind: "all",
message: message,
assimilate: fn input ->
errors =
for schematic <- schematics,
{result, message} = assimilate(schematic, input),
result == :error do
message
end
if Enum.empty?(errors) do
{:ok, input}
else
{:error, errors}
end
end
}
end
def oneof(schematics) when is_list(schematics) do
message = "either #{sentence_join(schematics, "or", & &1.message)}"
%Schematic{
kind: "oneof",
message: message,
assimilate: fn input ->
inquiry =
Enum.find_value(schematics, fn schematic ->
with {:error, _} <- assimilate(schematic, input) do
false
end
end)
with nil <- inquiry do
{:error, ~s|expected #{message}|}
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
def assimilate(schematic, input) do
schematic.assimilate.(input)
end
def optional(key) do
%OptionalKey{key: key}
end
end