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