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