Current section
Files
Jump to
Current section
Files
lib/lapis.ex
defmodule Lapis do
@moduledoc """
With `Lapis`, you can define schemas you can use to validate, transform, and reverse data.
The core building block of `Lapis` is **schema**. Schema could **parse** data (possibly with errors), and it could **reverse** parsed data back to its input.
`Lapis` allows collecting **multiple errors at once**, in `Lapis.Error`.
You can compose schemas to build complex validators and transformers.
`Lapis` provides many essential building blocks, all built on the same small core - `Lapis.Schema`.
You can define your own too.
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}}
iex> {:error, err} = Lapis.parse(person, %{age: "fifteen"})
iex> Enum.sort(err)
[expected_integer: [:age], missing_key: [:name]]
## Examples
### Parse and reverse coordinates
Parse a list of coordinates as tuples:
iex> schema =
...> Lapis.tuple({Lapis.number(), Lapis.number()})
...> |> Lapis.pipe(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.pipe/2` could be used:
iex> schema =
...> Lapis.tuple({Lapis.number(), Lapis.number()})
...> |> Lapis.pipe(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> alias Lapis, as: L
iex> schema =
...> L.choice([
...> L.literal(true),
...> L.literal(false),
...> L.literal(1) |> L.replace(true),
...> L.literal(0) |> L.replace(false),
...> L.literal("yes") |> L.replace(true),
...> L.literal("no") |> L.replace(false),
...> ])
iex> L.parse(schema, "yes")
{:ok, true}
iex> L.parse(schema, "no")
{:ok, false}
iex> L.parse(schema, false)
{:ok, false}
iex> L.parse(schema, 1)
{:ok, true}
iex> {:error, _} = L.parse(schema, -1)
### Errors handling
TODO
## Compile-Time Schemas
All utilities in Lapis are compile-time-friendly. Meaning, they don't use function captures and
therefore could be used in e.g. module attributes:
defmodule CompTimeTest do
alias Lapis, as: L
@schema L.number() |> L.positive() |> L.list()
def parse_num_list(data) do
@schema
|> L.parse(data)
end
end
Internally, it is done by assigning data necessary to parse/reverse to schemas (see `Lapis.Schema.assign/2`),
and by referencing parsers/reversers via "fully qualified specifiers" in the `Lapis.Internal` module (e.g. `Lapis.Internal.boolean_parse/1`).
Same strategy could be used for user-specified custom schemas. Maybe there is a better way to do so in Elixir, but I haven't found it yet.
## Inspiration
The key inspiration is the excelent TypeScript library [Zod](https://zod.dev).
Additional credits to:
- [`NimbleParsec`](https://hexdocs.pm/nimble_parsec/NimbleParsec.html)
- [`Parameter`](https://hexdocs.pm/parameter/Parameter.html)
- [`winnow`](https://docs.rs/winnow/0.7.13/winnow/index.html)
"""
alias Lapis.Internal
alias Lapis.Schema
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(
&Internal.list_parse/2,
&Internal.list_reverse/2
)
|> Schema.assign(list: schema)
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(&Internal.tuple_parse/2, &Internal.tuple_reverse/2)
|> Schema.assign(tuple: schemas)
end
@doc """
Define a schema for a fixed set of allowable literals.
This can be viewed as a convenience for using `Lapis.choice/2` 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(&Internal.enum_parse/2)
|> Schema.assign(enum: {map, vars})
end
@doc """
Define a schema for a value that must strictly match (`===/2`).
iex> Lapis.literal("La-pi-su") |> Lapis.parse("La-pi-su")
{:ok, "La-pi-su"}
"""
def literal(lit) do
Schema.new(&Internal.literal_parse/2)
|> Schema.assign(literal: lit)
end
@doc """
Define a schema for a string (`is_binary/1`).
iex> Lapis.string() |> Lapis.parse("Lapis")
{:ok, "Lapis"}
"""
def string() do
Schema.new(&Internal.string_parse/1)
end
@doc """
Define a schema for a boolean (`is_boolean/1`).
iex> Lapis.boolean() |> Lapis.parse(false)
{:ok, false}
"""
def boolean() do
Schema.new(&Internal.boolean_parse/1)
end
@doc """
Define a schema for any number, be it an integer or a floating-point number (`is_number/1`).
iex> Lapis.number() |> Lapis.parse(1)
{:ok, 1}
iex> Lapis.number() |> Lapis.parse(1.52)
{:ok, 1.52}
See also:
- `Lapis.integer/0`
"""
def number() do
Schema.new(&Internal.number_parse/1)
end
@doc """
Define a schema for an integer (`is_integer/1`).
iex> Lapis.integer() |> Lapis.parse(1)
{:ok, 1}
iex> {:error, _} = Lapis.integer() |> Lapis.parse(1.52)
"""
def integer() do
Schema.new(&Internal.integer_parse/1)
end
# TODO: float()
@doc """
Validate that a number is positive.
"""
def positive(schema) do
schema
|> refine(&Internal.refine_num_cmp/2, cmp: {:gt, 0})
end
@doc """
Validate that a number is non-negative.
"""
def nonnegative(schema) do
schema
|> refine(&Internal.refine_num_cmp/2, cmp: {:gte, 0})
end
@doc """
Validate that a number is non-positive.
"""
def nonpositive(schema) do
schema
|> refine(&Internal.refine_num_cmp/2, cmp: {:lte, 0})
end
@doc """
Validate that a number is negative.
"""
def negative(schema) do
schema
|> refine(&Internal.refine_num_cmp/2, cmp: {:lt, 0})
end
@doc """
Pipe a schema into another schema.
During **parsing**, data is first parsed with the _inner_ schema, then with the _outer_.
During **reversing**, data is first reversed with the _outer_ schema, then with the _inner_.
iex> str_sep = Lapis.Schema.new(&String.split(&1, " "), &Enum.join(&1, " "))
iex> two_str = Lapis.tuple({Lapis.string(), Lapis.string()})
iex> schema = Lapis.string() |> Lapis.pipe(str_sep) |> Lapis.pipe(two_str)
iex> Lapis.parse(schema, "1 2")
{:ok, {"1", "2"}}
iex> Lapis.reverse(schema, {"1", "2"})
"1 2"
iex> {:error, _} = Lapis.parse(schema, "1 2 3")
Piping holds the [associative property](https://en.wikipedia.org/wiki/Associative_property),
i.e. `(A |> B) |> C` equals to `A |> (B |> C)`:
iex> a = Lapis.string()
iex> b = Lapis.Schema.new(&String.length/1)
iex> c = Lapis.number()
iex> a |> Lapis.pipe(b) |> Lapis.pipe(c) |> Lapis.parse("hello")
{:ok, 5}
iex> a |> Lapis.pipe(b |> Lapis.pipe(c)) |> Lapis.parse("hello")
{:ok, 5}
"""
def pipe(%Schema{} = inner, %Schema{} = outer) do
Schema.new(&Internal.pipe_parse/2, &Internal.pipe_reverse/2)
|> Schema.assign(pipe: {inner, outer})
end
@doc """
Pipe a schema into a pair of parse/reverse functions.
iex> schema = Lapis.string() |> Lapis.pipe(&String.split(&1, " "))
iex> Lapis.parse(schema, "1 2 3")
{:ok, ["1", "2", "3"]}
See `Lapis.pipe/2` for more details.
"""
def pipe(%Schema{} = inner, parse, reverse \\ &Function.identity/1) do
pipe(inner, Schema.new(parse, reverse))
end
@doc ~S"""
Refine a schema with a custom validation. Shorthand for `Lapis.Schema.refine/3`.
Refines extend the schema without transforming data. Schema could have many refines,
all of which run **after** the main parser. If any of them fail, entire parsing fails.
iex> schema =
...> Lapis.string()
...> |> Lapis.refine(& if(&1 =~ ~r[lapis], do: :ok, else: {:error, "not lapis"}))
...> |> Lapis.refine(& if(&1 =~ ~r[philosophorum], do: :ok, else: {:error, "not philosophorum"}))
iex> Lapis.parse(schema, "lapis philosophorum")
{:ok, "lapis philosophorum"}
iex> {:error, error} = Lapis.parse(schema, "philosophorum")
iex> Enum.to_list(error)
[{"not lapis", []}]
iex> {:error, error} = Lapis.parse(schema, "lapidarius")
iex> Enum.sort(error)
[{"not lapis", []}, {"not philosophorum", []}]
Each refinement may have its own _assigns_, to enable reusability and compile-time schemas:
iex> match_regex =
...> fn input, %{regex: r} ->
...> if input =~ r do
...> :ok
...> else
...> {:error, "does not match regex: #{inspect(r)}"}
...> end
...> end
iex> schema =
...> Lapis.string()
...> |> Lapis.refine(match_regex, regex: ~r[lapis])
...> |> Lapis.refine(match_regex, regex: ~r[philosophorum])
iex> Lapis.parse(schema, "lapis philosophorum")
{:ok, "lapis philosophorum"}
iex> {:error, error} = Lapis.parse(schema, "philosophorum")
iex> Enum.to_list(error)
[{"does not match regex: ~r/lapis/", []}]
Currently refinements have access only to its own assigns, not the schema's.
"""
def refine(%Schema{} = schema, fun, assigns \\ []) when is_function(fun) do
Schema.refine(schema, fun, assigns)
end
@doc """
Pick the first successful parser.
If all parsers fail, errors from all of them are collected.
iex> schema = Lapis.choice([Lapis.string(), Lapis.number()])
iex> Lapis.parse(schema, "5")
{:ok, "5"}
iex> Lapis.parse(schema, 51)
{:ok, 51}
iex> {:error, error} = Lapis.parse(schema, false)
iex> [{{:all_choices_failed, %Lapis.Error{} = error}, []}] = Enum.to_list(error)
iex> Enum.sort(error)
[{:expected_number, []}, {:expected_string, []}]
Reversing has no way of knowing which schema to use for that, therefore it must be provided manually.
Default reverse simply returns its input.
"""
def choice(choices, reverse \\ &Function.identity/1) when is_list(choices) do
Schema.new(&Internal.choice_parse/2, reverse)
|> Schema.assign(choice: choices)
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.pipe/2`:
iex> schema = Lapis.literal("yes") |> Lapis.pipe(fn _ -> true end)
iex> Lapis.parse(schema, "yes")
{:ok, true}
"""
def replace(schema, value) do
pipe(schema, Schema.new(&Internal.replace_parse/2) |> Schema.assign(replace: value))
end
@doc """
Validate that the input string matches the given regex.
This is a _refine_ that expects an input to be a string.
iex> schema = Lapis.string() |> Lapis.regex(~r[la])
iex> {:ok, _} = Lapis.parse(schema, "lapis")
iex> {:ok, _} = Lapis.parse(schema, "lazuli")
iex> {:error, _} = Lapis.parse(schema, "lorem")
"""
def regex(schema, %Regex{} = regex) do
refine(schema, &Lapis.Internal.refine_regex/2, regex: regex)
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