Current section
Files
Jump to
Current section
Files
lib/lapis.ex
defmodule Lapis do
@moduledoc """
Schema-based, general-purpose, dead-simple bidirectional data transformation and validation.
Quick example:
iex> person =
...> Lapis.map(
...> name: Lapis.string(),
...> nickname: Lapis.string() |> Lapis.optional(),
...> age: Lapis.integer() |> Lapis.positive()
...> )
iex> Lapis.parse(person, %{"name" => "Marcius", "age" => 2665})
{:ok, %{name: "Marcius", age: 2665}}
## Principles
- All revolves around **schemas** (`Lapis.Schema`).
- **Parse** raw material (`Lapis.parse/2`). This is a fallible operation.
- **Reverse** parsed output back to the raw material (`Lapis.reverse/2`). This shall never fail. This is optional.
- **Refine** schemas with additional validations without transformation (`Lapis.refine/2`).
- **Wrap** schemas one into another to compose transformations (`Lapis.wrap/3`).
- Collect **all errors** with their locations.
## Examples
### Parse and reverse coordinates
Parse a list of coordinates as tuples:
iex> schema =
...> Lapis.tuple({Lapis.number(), Lapis.number()})
...> |> Lapis.wrap(fn {x, y} -> %{x: x, y: y} end)
...> |> Lapis.list()
iex> Lapis.parse(schema, [[1, 2], [3, 4]])
{:ok, [%{x: 1, y: 2}, %{x: 3, y: 4}]}
To reverse the output back, the second argument to `Lapis.wrap/2` could be used:
iex> schema =
...> Lapis.tuple({Lapis.number(), Lapis.number()})
...> |> Lapis.wrap(fn {x, y} -> %{x: x, y: y} end, fn out -> {out.x, out.y} end)
...> |> Lapis.list()
iex> Lapis.reverse(schema, [%{x: 1, y: 2}, %{x: 3, y: 4}])
[[1, 2], [3, 4]]
### Parse yes/no values
iex> schema =
...> Lapis.choice([
...> Lapis.literal(true),
...> Lapis.literal(false),
...> Lapis.literal(1) |> Lapis.replace(true),
...> Lapis.literal(0) |> Lapis.replace(false),
...> Lapis.literal("yes") |> Lapis.replace(true),
...> Lapis.literal("no") |> Lapis.replace(false),
...> ])
iex> Lapis.parse(schema, "yes")
{:ok, true}
iex> Lapis.parse(schema, "no")
{:ok, false}
iex> Lapis.parse(schema, false)
{:ok, false}
iex> Lapis.parse(schema, 1)
{:ok, true}
iex> {:error, _} = Lapis.parse(schema, -1)
### Errors handling
TODO
## Todo
Things that may be useful or just exciting to implement:
- More out-of-the box utilities. Regexes, patterns, dates, so many of them. They aren't necessary, as core tools to implement them on user-side are given.
- Recursive schemas (i.e. lazy dispatch)?
- Compile-time schemas support.
## Inspiration
The key inspiration is the excelent TypeScript library [Zod](https://zod.dev).
"""
alias Lapis.Util
alias Lapis.Schema
alias Lapis.Error
alias __MODULE__.Map, as: LMap
@doc """
Create a schema of a map. Shorthand for `Lapis.Map.new/2`.
See `Lapis.Map` module for tips and tricks.
"""
def map(fields, opts \\ []) do
LMap.new(fields, opts)
end
@doc """
Mark map key as optional. Shorthand for `Lapis.Map.key_optional/1`.
"""
def optional(schema) do
LMap.key_optional(schema)
end
@doc """
Mark map key as required. Shorthand for `Lapis.Map.key_required/1`.
"""
def required(schema) do
LMap.key_required(schema)
end
@doc """
Provide a default value for a map entry if the key is absent in the input. Shorthand for `Lapis.Map.key_default/2`.
"""
def default(schema, lazy) do
LMap.key_default(schema, lazy)
end
@doc """
Rename a map key. Shorthand for `Lapis.Map.key_rename/2`.
"""
def rename(schema, name) do
LMap.key_rename(schema, name)
end
@doc """
Define a schema for a list.
All elements of the list are parsed/reversed with the given schema.
"""
def list(schema) do
Schema.new(
fn
input when is_list(input) ->
input
|> Stream.with_index()
|> Enum.reduce({:ok, []}, fn {elem, index}, acc ->
Schema.parse(schema, elem)
|> case do
{:ok, value} ->
case acc do
{:ok, acc} -> {:ok, [value | acc]}
{:error, _} = acc -> acc
end
{:error, error} ->
acc |> Util.result_collect(error |> Error.put_loc(index))
end
end)
|> case do
{:ok, reversed} -> {:ok, Enum.reverse(reversed)}
x -> x
end
_ ->
{:error, :expected_array}
end,
fn output when is_list(output) ->
output
|> Enum.map(&Schema.reverse(schema, &1))
end
)
end
@doc """
Define a schema for a tuple.
Tuple is a fixed-length sequence of schemas. Schemas themselves may be either a tuple or a list.
The *input* may be a tuple or a list. The length of input must match the length of the schemas.
The *output* is always a tuple.
Reverse conversion **always produces lists**. This is done in favor of JSON encoders and JSON format in general that does not have "tuples" but only arrays. (TODO: provide an option to reverse as a tuple?)
iex> schema = Lapis.tuple({Lapis.string(), Lapis.number()})
iex> Lapis.parse(schema, {"hey", 5}) # tuple input
{:ok, {"hey", 5}}
iex> Lapis.parse(schema, ["hey", 5]) # list input
{:ok, {"hey", 5}}
iex> {:error, _} = Lapis.parse(schema, ["hey", 5, 1])
iex> Lapis.reverse(schema, {"reverse", 42})
["reverse", 42]
"""
def tuple(schemas) when is_tuple(schemas) do
tuple(Tuple.to_list(schemas))
end
def tuple(schemas) when is_list(schemas) do
Schema.new(
fn
input when is_list(input) ->
tuple_parse(input, schemas, [], 0)
input when is_tuple(input) ->
tuple_parse(Tuple.to_list(input), schemas, [], 0)
_ ->
{:error, :expected_array_or_tuple}
end,
fn output when is_tuple(output) ->
output
|> Tuple.to_list()
|> Stream.zip(schemas)
|> Enum.map(fn {output, schema} -> Schema.reverse(schema, output) end)
end
)
end
defp tuple_parse([], [], acc, _), do: {:ok, Enum.reverse(acc) |> List.to_tuple()}
defp tuple_parse([_], [], _, _), do: {:error, Error.new(:input_tuple_too_large)}
defp tuple_parse([], [_], _, _), do: {:error, Error.new(:input_tuple_too_small)}
defp tuple_parse([input | input_tail], [schema | schema_tail], acc, pos) do
Schema.parse(schema, input)
|> case do
{:ok, output} ->
tuple_parse(input_tail, schema_tail, [output | acc], pos + 1)
{:error, err} ->
{:error, Error.put_loc(err, pos)}
end
end
@doc """
Define a schema for a fixed set of allowable literals.
This can be viewed as a convenience for using `Lapis.choice/1` with a set of `Lapis.literal/1`.
However, **atoms are handled specially:** during _parsing_ they are checked against both atoms and their respective
strings.
A canonical example would be using a list of atoms:
iex> fish = Lapis.enum([:salmon, :tuna, :trout])
iex> Lapis.parse(fish, :salmon)
{:ok, :salmon}
iex> Lapis.parse(fish, "salmon")
{:ok, :salmon}
However, any values could be used:
iex> schema = Lapis.enum(["yes", "no", true, false, 1, 0])
iex> Lapis.parse(schema, "yes")
{:ok, "yes"}
iex> Lapis.parse(schema, "no")
{:ok, "no"}
iex> Lapis.parse(schema, 1)
{:ok, 1}
iex> {:error, _} = Lapis.parse(schema, 2)
"""
def enum(vars) do
map =
vars
|> Enum.reduce(%{}, fn var, acc ->
acc = Map.put_new(acc, var, var)
if is_atom(var) do
Map.put_new(acc, Atom.to_string(var), var)
else
acc
end
end)
Schema.new(fn
input when is_map_key(map, input) -> {:ok, map[input]}
_ -> {:error, {:expected_one_of, vars}}
end)
end
@doc """
Define a schema for a value that must strictly match (`===/2`).
"""
def literal(lit) do
Schema.new(fn x ->
if x === lit do
{:ok, lit}
else
{:error, {:expected_literal, lit}}
end
end)
end
@doc """
Define a schema for a string (`is_binary/1`).
"""
def string() do
Schema.new(fn
x when is_binary(x) -> {:ok, x}
_ -> {:error, :expected_string}
end)
end
@doc """
Define a schema for a boolean (`is_boolean/1`).
iex> Lapis.boolean() |> Lapis.parse(false)
{:ok, false}
"""
def boolean() do
Schema.new(fn
x when is_boolean(x) -> {:ok, x}
_ -> {:error, :expected_boolean}
end)
end
@doc """
Define a schema for any number, be it an integer or a floating-point number (`is_number/1`).
See also:
- `Lapis.integer/0`
"""
def number() do
Schema.new(fn
x when is_number(x) -> {:ok, x}
_ -> {:error, :expected_number}
end)
end
@doc """
Define a schema for an integer (`is_integer/1`).
"""
def integer() do
Schema.new(fn
x when is_integer(x) -> {:ok, x}
_ -> {:error, :expected_integer}
end)
end
@doc """
Validate that a number is positive.
"""
def positive(schema) do
refine(schema, fn
x when is_number(x) and x > 0 -> :ok
_ -> {:error, :expected_positive}
end)
end
@doc """
Validate that a number is non-negative.
"""
def nonnegative(schema) do
refine(schema, fn
x when is_number(x) and x >= 0 -> :ok
_ -> {:error, :expected_nonnegative}
end)
end
@doc """
Validate that a number is non-positive.
"""
def nonpositive(schema) do
refine(schema, fn
x when is_number(x) and x <= 0 -> :ok
_ -> {:error, :expected_nonpositive}
end)
end
@doc """
Validate that a number is negative.
"""
def negative(schema) do
refine(schema, fn
x when is_number(x) and x < 0 -> :ok
_ -> {:error, :expected_negative}
end)
end
@doc """
Wrap a schema with another schema. Shorthand for `Lapis.Schema.wrap/2`.
"""
def wrap(%Schema{} = schema, parse, reverse \\ & &1)
when is_function(parse, 1) and is_function(reverse, 1) do
Schema.wrap(schema, Schema.new(parse, reverse))
end
@doc """
Refine a schema with a custom validation. Shorthand for `Lapis.Schema.refine/2`.
"""
def refine(%Schema{} = schema, fun) when is_function(fun, 1) do
Schema.refine(schema, fun)
end
@doc """
Combine schemas with a logical "OR".
This schema tries every schema until some succeeds. If all fail, errors
from all of them are collected.
"""
def choice([first | [_ | _]] = choices) do
Schema.new(
&try_parse_choices(&1, choices, nil),
&Schema.reverse(first, &1)
)
end
defp try_parse_choices(_, [], error), do: {:error, {:all_choices_failed, error}}
defp try_parse_choices(input, [choice | tail], error_acc) do
case Schema.parse(choice, input) do
{:ok, _} = ok ->
ok
{:error, reason} ->
error =
case error_acc do
nil -> reason
prev -> Error.concat(prev, reason)
end
try_parse_choices(input, tail, error)
end
end
@doc """
Replace schema output with a value.
iex> schema = Lapis.literal("yes") |> Lapis.replace(true)
iex> Lapis.parse(schema, "yes")
{:ok, true}
Equivalent with `Lapis.wrap/2`:
iex> schema = Lapis.literal("yes") |> Lapis.wrap(fn _ -> true end)
iex> Lapis.parse(schema, "yes")
{:ok, true}
"""
def replace(schema, value) do
wrap(schema, fn _ -> value end)
end
@doc """
Parse input with the given schema. Shorthand for `Lapis.Schema.parse/2`.
"""
def parse(schema, input) do
Schema.parse(schema, input)
end
@doc """
Reverse the output back with the given schema. Shorthand for `Lapis.Schema.reverse/2`
"""
def reverse(schema, output) do
Schema.reverse(schema, output)
end
end