Current section
Files
Jump to
Current section
Files
lib/schematic.ex
defmodule Schematic do
defstruct [:assimilate, :kind, :message]
defmodule OptionalKey do
@enforce_keys [:key]
defstruct [:key]
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 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 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 func(function, opts \\ []) do
message = Keyword.fetch!(opts, :message)
%Schematic{
kind: "function",
message: message,
assimilate: fn input ->
if function.(input) do
{:ok, 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