defmodule Zoi do @moduledoc """ `Zoi` is a schema validation library for Elixir, designed to provide a simple and flexible way to define and validate data. It allows you to create schemas for various data types, including strings, integers, booleans, and complex objects, with built-in support for validations like minimum and maximum values, regex patterns, and email formats. user = Zoi.object(%{ name: Zoi.string() |> Zoi.min(2) |> Zoi.max(100), age: Zoi.integer() |> Zoi.min(18) |> Zoi.max(120), email: Zoi.string() |> Zoi.email() }) Zoi.parse(user, %{ name: "Alice", age: 30, email: "alice@email.com" }) # {:ok, %{name: "Alice", age: 30, email: "alice@email.com"}} ## Schemas `Zoi` schemas are defined using a set of functions that create types and validations. Primitive types: Zoi.string() Zoi.integer() Zoi.float() Zoi.number() Zoi.boolean() Encapsulated types: Zoi.optional(inner_type) Zoi.default(inner_type, default_value) Zoi.union(fields) Complex types: Zoi.object(fields) Zoi.enum(values) Zoi.array(inner_type) Zoi.tuple(inner_types) ## Coercion By default, `Zoi` will not attempt to infer input data to match the expected type. For example, if you define a schema that expects a string, passing an integer will result in an error. iex> Zoi.string() |> Zoi.parse(123) {:error, [%Zoi.Error{message: "invalid string type"}]} If you need coercion, you can enable it by passing the `:coerce` option: iex> Zoi.string(coerce: true) |> Zoi.parse(123) {:ok, "123"} ## Custom errors You can customize parsing error messages the primitive types by passing the `error` option: iex> schema = Zoi.integer(error: "must be a number") iex> Zoi.parse(schema, "a") {:error, [%Zoi.Error{message: "must be a number"}]} """ alias Zoi.Types.Meta @type input :: any() @type result :: {:ok, any()} | {:error, [Zoi.Error.t() | binary()]} @type options :: keyword() @doc """ Parse input data against a schema. Accepts optional `coerce: true` option to enable coercion. ## Examples iex> schema = Zoi.string() |> Zoi.min(2) |> Zoi.max(100) iex> Zoi.parse(schema, "hello") {:ok, "hello"} iex> Zoi.parse(schema, "hi") {:error, [%Zoi.Error{message: "minimum length is 2"}]} iex> Zoi.parse(schema, 123, coerce: true) {:ok, "123"} """ @doc group: "Parsing" @spec parse(schema :: Zoi.Type.t(), input :: input(), opts :: options) :: result() def parse(schema, input, opts \\ []) do with {:ok, result} <- Zoi.Type.parse(schema, input, opts), {:ok, _refined_result} <- Meta.run_refinements(schema, result), {:ok, result} <- Meta.run_transforms(schema, result) do {:ok, result} else {:error, reason} when is_binary(reason) -> {:error, Zoi.Errors.add_error(reason)} {:error, error} -> {:error, error} end end # Types @doc """ Defines a string type schema. ## Example Zoi provides built-in validations for strings, such as: Zoi.min(2) Zoi.max(100) Zoi.length(5) Zoi.regex(~r/^[a-zA-Z]+$/) Additionally it can perform data transformation: Zoi.string() |> Zoi.trim() |> Zoi.downcase() |> Zoi.uppercase() Zoi also supports validating formats: Zoi.email() # pattern ~r/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/i """ @doc group: "Basic Types" defdelegate string(opts \\ []), to: Zoi.Types.String, as: :new @doc """ Defines a number type schema. ## Example iex> shema = Zoi.integer() iex> Zoi.parse(shema, 42) {:ok, 42} Built-in validations for integers include: Zoi.min(0) Zoi.max(100) """ @doc group: "Basic Types" defdelegate integer(opts \\ []), to: Zoi.Types.Integer, as: :new @doc """ Defines a float type schema. ## Example iex> schema = Zoi.float() iex> Zoi.parse(schema, 3.14) {:ok, 3.14} Built-in validations for floats include: Zoi.min(0.0) Zoi.max(100.0) For coercion, you can pass the `:coerce` option: iex> Zoi.float(coerce: true) |> Zoi.parse("3.14") {:ok, 3.14} """ @doc group: "Basic Types" defdelegate float(opts \\ []), to: Zoi.Types.Float, as: :new @doc """ Defines the numeric type schema. This type is a union of `Zoi.integer()` and `Zoi.float()`, allowing you to validate both integers and floats. ## Example iex> schema = Zoi.number() iex> Zoi.parse(schema, 42) {:ok, 42} iex> Zoi.parse(schema, 3.14) {:ok, 3.14} """ @doc group: "Basic Types" defdelegate number(opts \\ []), to: Zoi.Types.Number, as: :new @doc """ Defines a boolean type schema. ## Example iex> schema = Zoi.boolean() iex> Zoi.parse(schema, true) {:ok, true} For coercion, you can pass the `:coerce` option: iex> Zoi.boolean(coerce: true) |> Zoi.parse("true") {:ok, true} """ @doc group: "Basic Types" defdelegate boolean(opts \\ []), to: Zoi.Types.Boolean, as: :new @doc """ Makes the schema optional for the `Zoi.object/2` type. ## Example iex> schema = Zoi.object(%{name: Zoi.string() |> Zoi.optional()}) iex> Zoi.parse(schema, %{}) {:ok, %{}} """ @doc group: "Encapsulated Types" defdelegate optional(opts \\ []), to: Zoi.Types.Optional, as: :new @doc """ Creates a default value for the schema. This allows you to specify a default value that will be used if the input is `nil` or not provided. ## Example iex> schema = Zoi.string() |> Zoi.default("default value") iex> Zoi.parse(schema, nil) {:ok, "default value"} """ @doc group: "Encapsulated Types" defdelegate default(inner, value, opts \\ []), to: Zoi.Types.Default, as: :new @doc """ Defines a union type schema. ## Example iex> schema = Zoi.union([Zoi.string(), Zoi.integer()]) iex> Zoi.parse(schema, "hello") {:ok, "hello"} iex> Zoi.parse(schema, 42) {:ok, 42} iex> Zoi.parse(schema, true) {:error, [%Zoi.Error{message: "invalid type for union"}]} This type also allows to define validations for each type in the union: iex> schema = Zoi.union([ ...> Zoi.string() |> Zoi.min(2), ...> Zoi.integer() |> Zoi.min(0) ...> ]) iex> Zoi.parse(schema, "hi") {:error, [%Zoi.Error{message: "minimum length is 2"}]} iex> Zoi.parse(schema, -1) {:error, [%Zoi.Error{message: "minimum value is 0"}]} If you define the validation on the union itself, it will apply to all types in the union: iex> schema = Zoi.union([ ...> Zoi.string(), ...> Zoi.integer() ...> ]) |> Zoi.min(3) iex> Zoi.parse(schema, "hello") {:ok, "hello"} iex> Zoi.parse(schema, 2) {:error, [%Zoi.Error{message: "minimum value is 3"}]} """ @doc group: "Encapsulated Types" defdelegate union(fields, opts \\ []), to: Zoi.Types.Union, as: :new @doc """ Defines a object type schema. Use `Zoi.object(fields)` to define complex objects with nested schemas: user_schema = Zoi.object(%{ name: Zoi.string() |> Zoi.min(2) |> Zoi.max(100), age: Zoi.integer() |> Zoi.min(18) |> Zoi.max(120), email: Zoi.string() |> Zoi.email() }) iex> Zoi.parse(user_schema, %{name: "Alice", age: 30, email: "alice@email.com"}) {:ok, %{name: "Alice", age: 30, email: "alice@email.com"}} By default all fields are required, but you can make them optional by using `Zoi.optional/1`: user_schema = Zoi.object(%{ name: Zoi.string() |> Zoi.optional(), age: Zoi.integer() |> Zoi.optional(), email: Zoi.string() |> Zoi.email() |> Zoi.optional() }) iex> Zoi.parse(user_schema, %{name: "Alice"}) {:ok, %{name: "Alice"}} By default, unrecognized keys will be removed from the parsed data. If you want to not allow unrecognized keys, use the `:strict` option: iex> schema = Zoi.object(%{name: Zoi.string()}, strict: true) iex> Zoi.parse(schema, %{name: "Alice", age: 30}) {:error, [%Zoi.Error{message: "unrecognized key: 'age'"}]} """ @doc group: "Complex Types" defdelegate object(fields, opts \\ []), to: Zoi.Types.Object, as: :new @doc """ Defines a array type schema. Use `Zoi.array(elements)` to define an array of a specific type: iex> schema = Zoi.array(Zoi.string()) iex> Zoi.parse(schema, ["hello", "world"]) {:ok, ["hello", "world"]} iex> Zoi.parse(schema, ["hello", 123]) {:error, [%Zoi.Error{message: "invalid string type", path: [1]}]} Built-in validations for integers include: Zoi.min(3) Zoi.max(10) Zoi.length(5) """ @doc group: "Complex Types" defdelegate array(elements, opts \\ []), to: Zoi.Types.Array, as: :new @doc """ Defines an enum type schema. Use `Zoi.enum(values)` to define a schema that accepts only specific values: iex> schema = Zoi.enum([:red, :green, :blue]) iex> Zoi.parse(schema, :red) {:ok, :red} iex> Zoi.parse(schema, :yellow) {:error, [%Zoi.Error{message: "invalid value for enum"}]} You can also specify enum as strings: iex> schema = Zoi.enum(["red", "green", "blue"]) iex> Zoi.parse(schema, "red") {:ok, "red"} iex> Zoi.parse(schema, "yellow") {:error, [%Zoi.Error{message: "invalid value for enum"}]} or with key-value pairs: iex> schema = Zoi.enum([red: "Red", green: "Green", blue: "Blue"]) iex> Zoi.parse(schema, "Red") {:ok, :red} iex> Zoi.parse(schema, "Yellow") {:error, [%Zoi.Error{message: "invalid value for enum"}]} Integer values can also be used: iex> schema = Zoi.enum([1, 2, 3]) iex> Zoi.parse(schema, 1) {:ok, 1} iex> Zoi.parse(schema, 4) {:error, [%Zoi.Error{message: "invalid value for enum"}]} And Integers with key-value pairs also is allowed: iex> schema = Zoi.enum([one: 1, two: 2, three: 3]) iex> Zoi.parse(schema, 1) {:ok, :one} iex> Zoi.parse(schema, 4) {:error, [%Zoi.Error{message: "invalid value for enum"}]} """ @doc group: "Complex Types" defdelegate enum(values, opts \\ []), to: Zoi.Types.Enum, as: :new # Refinements @doc """ Validates that the string has a specific length. ## Example iex> schema = Zoi.string() |> Zoi.length(5) iex> Zoi.parse(schema, "hello") {:ok, "hello"} iex> Zoi.parse(schema, "hi") {:error, [%Zoi.Error{message: "length must be 5"}]} """ @doc group: "Refinements" @spec length(schema :: Zoi.Type.t(), length :: non_neg_integer()) :: Zoi.Type.t() def length(schema, length) do schema |> refine({Zoi.Refinements, :refine, [[length: length], []]}) end @doc """ Validates that the input is greater than or equal to a minimum value. This can be used for strings, integers, floats and numbers. ## Example iex> schema = Zoi.string() |> Zoi.min(2) iex> Zoi.parse(schema, "hello") {:ok, "hello"} iex> Zoi.parse(schema, "hi") {:error, [%Zoi.Error{message: "minimum length is 2"}]} """ @doc group: "Refinements" @spec min(schema :: Zoi.Type.t(), min :: non_neg_integer()) :: Zoi.Type.t() def min(schema, min) do schema |> refine({Zoi.Refinements, :refine, [[min: min], []]}) end @doc """ Validates that the input is less than or equal to a maximum value. This can be used for strings, integers, floats and numbers. ## Example iex> schema = Zoi.string() |> Zoi.max(5) iex> Zoi.parse(schema, "hello") {:ok, "hello"} iex> Zoi.parse(schema, "hello world") {:error, [%Zoi.Error{message: "maximum length is 5"}]} """ @doc group: "Refinements" def max(schema, max) do schema |> refine({Zoi.Refinements, :refine, [[max: max], []]}) end @doc """ Validates that the input matches a given regex pattern. ## Example iex> schema = Zoi.string() |> Zoi.regex(~r/^\d+$/) iex> Zoi.parse(schema, "12345") {:ok, "12345"} """ @doc group: "Refinements" def regex(schema, regex, opts \\ []) do schema |> refine({Zoi.Refinements, :refine, [[regex: regex], opts]}) end @doc """ Validates that the string is a valid email format. ## Example iex> schema = Zoi.string() |> Zoi.email() iex> Zoi.parse(schema, "test@test.com") {:ok, "test@test.com"} iex> Zoi.parse(schema, "invalid-email") {:error, [%Zoi.Error{message: "invalid email format"}]} """ @doc group: "Refinements" @spec email(schema :: Zoi.Type.t()) :: Zoi.Type.t() def email(%Zoi.Types.String{} = schema) do schema |> regex( ~r/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/i, message: "invalid email format" ) end @doc """ Validates that a string starts with a specific prefix. ## Example iex> schema = Zoi.string() |> Zoi.starts_with("hello") iex> Zoi.parse(schema, "hello world") {:ok, "hello world"} iex> Zoi.parse(schema, "world hello") {:error, [%Zoi.Error{message: "must start with 'hello'"}]} """ @doc group: "Refinements" @spec starts_with(schema :: Zoi.Type.t(), prefix :: binary()) :: Zoi.Type.t() def starts_with(schema, prefix) do schema |> refine({Zoi.Refinements, :refine, [[starts_with: prefix], []]}) end @doc """ Validates that a string ends with a specific suffix. ## Example iex> schema = Zoi.string() |> Zoi.ends_with("world") iex> Zoi.parse(schema, "hello world") {:ok, "hello world"} iex> Zoi.parse(schema, "hello") {:error, [%Zoi.Error{message: "must end with 'world'"}]} """ @doc group: "Refinements" @spec ends_with(schema :: Zoi.Type.t(), suffix :: binary()) :: Zoi.Type.t() def ends_with(schema, suffix) do schema |> refine({Zoi.Refinements, :refine, [[ends_with: suffix], []]}) end # Transforms @doc """ Trims whitespace from the beginning and end of a string. ## Example iex> schema = Zoi.string() |> Zoi.trim() iex> Zoi.parse(schema, " hello world ") {:ok, "hello world"} """ @doc group: "Transforms" @spec trim(schema :: Zoi.Type.t()) :: Zoi.Type.t() def trim(schema) do schema |> transform({Zoi.Transforms, :transform, [[:trim]]}) end @doc """ Converts a string to lowercase. ## Example iex> schema = Zoi.string() |> Zoi.to_downcase() iex> Zoi.parse(schema, "Hello World") {:ok, "hello world"} """ @doc group: "Transforms" def to_downcase(schema) do schema |> transform({Zoi.Transforms, :transform, [[:to_downcase]]}) end @doc """ Converts a string to uppercase. ## Example iex> schema = Zoi.string() |> Zoi.to_upcase() iex> Zoi.parse(schema, "Hello World") {:ok, "HELLO WORLD"} """ @doc group: "Transforms" @spec to_upcase(schema :: Zoi.Type.t()) :: Zoi.Type.t() def to_upcase(schema) do schema |> transform({Zoi.Transforms, :transform, [[:to_upcase]]}) end @doc """ Adds a custom validation function to the schema. This function will be called with the input data and options, and should return `:ok` for valid data or `{:error, reason}` for invalid data. ## Example iex> schema = Zoi.string() |> Zoi.refine(fn input, _opts -> ...> if String.length(input) > 5 do ...> :ok ...> else ...> {:error, "must be longer than 5 characters"} ...> end ...> end) iex> Zoi.parse(schema, "hello world") {:ok, "hello world"} iex> Zoi.parse(schema, "hi") {:error, [%Zoi.Error{message: "must be longer than 5 characters"}]} """ @doc group: "Extensions" @spec refine(schema :: Zoi.Type.t(), fun :: Meta.refinement()) :: Zoi.Type.t() def refine(%Zoi.Types.Union{schemas: schemas} = schema, fun) do schemas = Enum.map(schemas, fn sub_schema -> refine(sub_schema, fun) end) %Zoi.Types.Union{schema | schemas: schemas} end def refine(schema, fun) do update_in(schema.meta.refinements, fn transforms -> transforms ++ [fun] end) end @doc """ Adds a transformation function to the schema. This function will be applied to the input data after parsing but before validations. ## Example iex> schema = Zoi.string() |> Zoi.transform(&String.trim/1) iex> Zoi.parse(schema, " hello world ") {:ok, "hello world"} """ @doc group: "Extensions" @spec transform(schema :: Zoi.Type.t(), fun :: Meta.transform()) :: Zoi.Type.t() def transform(%Zoi.Types.Union{schemas: schemas} = schema, fun) do schemas = Enum.map(schemas, fn sub_schema -> transform(sub_schema, fun) end) %Zoi.Types.Union{schema | schemas: schemas} end @spec transform(schema :: Zoi.Type.t(), fun :: function()) :: Zoi.Type.t() def transform(schema, fun) do update_in(schema.meta.transforms, fn transforms -> transforms ++ [fun] end) end @doc """ Converts a list of errors into a tree structure, where each error is placed at its corresponding path. This is useful for displaying validation errors in a structured way, such as in a form. ## Example iex> errors = [ ...> %Zoi.Error{path: ["name"], message: "is required"}, ...> %Zoi.Error{path: ["age"], message: "must be a number"}, ...> %Zoi.Error{path: ["address", "city"], message: "is required"} ...> ] iex> Zoi.treefy_errors(errors) %{ "name" => [%Zoi.Error{message: "is required"}], "age" => [%Zoi.Error{message: "must be a number"}], "address" => %{ "city" => [%Zoi.Error{message: "is required"}] } } """ @spec treefy_errors([Zoi.Error.t()]) :: map() def treefy_errors(errors) when is_list(errors) do Enum.reduce(errors, %{}, fn %Zoi.Error{path: path} = error, acc -> insert_error(acc, path, error) end) end defp insert_error(acc, [], _error), do: acc defp insert_error(acc, [key], error) do Map.update(acc, key, [error], fn existing -> existing ++ [error] end) end defp insert_error(acc, [key | rest], error) do nested = Map.get(acc, key, %{}) Map.put(acc, key, insert_error(nested, rest, error)) end end