Lapis (Lapis v0.2.0)
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
endInternally, 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.
Additional credits to:
Summary
Functions
Define a schema for a boolean (is_boolean/1).
Pick the first successful parser.
Provide a default value for a map entry if the key is absent in the input. Shorthand for Lapis.Map.key_default/2.
Define a schema for a fixed set of allowable literals.
Define a schema for an integer (is_integer/1).
Define a schema for a list.
Define a schema for a value that must strictly match (===/2).
Create a schema of a map. Shorthand for Lapis.Map.new/2.
Validate that a number is negative.
Validate that a number is non-negative.
Validate that a number is non-positive.
Define a schema for any number, be it an integer or a floating-point number (is_number/1).
Mark map key as optional. Shorthand for Lapis.Map.key_optional/1.
Parse input with the given schema. Shorthand for Lapis.Schema.parse/2.
Pipe a schema into another schema.
Pipe a schema into a pair of parse/reverse functions.
Validate that a number is positive.
Refine a schema with a custom validation. Shorthand for Lapis.Schema.refine/3.
Validate that the input string matches the given regex.
Rename a map key. Shorthand for Lapis.Map.key_rename/2.
Replace schema output with a value.
Mark map key as required. Shorthand for Lapis.Map.key_required/1.
Reverse the output back with the given schema. Shorthand for Lapis.Schema.reverse/2
Define a schema for a string (is_binary/1).
Define a schema for a tuple.
Functions
Define a schema for a boolean (is_boolean/1).
iex> Lapis.boolean() |> Lapis.parse(false)
{:ok, false}
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.
Provide a default value for a map entry if the key is absent in the input. Shorthand for Lapis.Map.key_default/2.
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)
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)
Define a schema for a list.
All elements of the list are parsed/reversed with the given schema.
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"}
Create a schema of a map. Shorthand for Lapis.Map.new/2.
See Lapis.Map module for tips and tricks.
Validate that a number is negative.
Validate that a number is non-negative.
Validate that a number is non-positive.
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:
Mark map key as optional. Shorthand for Lapis.Map.key_optional/1.
Parse input with the given schema. Shorthand for Lapis.Schema.parse/2.
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,
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}
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.
Validate that a number is positive.
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.
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")
Rename a map key. Shorthand for Lapis.Map.key_rename/2.
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}
Mark map key as required. Shorthand for Lapis.Map.key_required/1.
Reverse the output back with the given schema. Shorthand for Lapis.Schema.reverse/2
Define a schema for a string (is_binary/1).
iex> Lapis.string() |> Lapis.parse("Lapis")
{:ok, "Lapis"}
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]