defmodule Zot do @moduledoc ~S""" Schema parser and validator for Elixir. """ import Zot.Utils, only: [is_mfa: 1, type: 1] alias Zot.Context # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # CORE API # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @doc ~S""" Parses the given input according to the given type. """ @spec parse(type, input, [option]) :: {:ok, output} | {:error, [Zot.Issue.t(), ...]} when type: Zot.Type.t(), input: term, option: {:coerce, boolean | :unsafe}, output: term def parse(%_{} = type, input, opts \\ []) do Context.new(type, input, opts) |> Context.parse() |> Context.unwrap() end @doc ~S""" Converts the given type into a JSON Schema. """ @spec json_schema(type) :: map when type: Zot.Type.t() def json_schema(type(_) = type) do Zot.Type.json_schema(type) |> Enum.reject(fn {_, value} -> is_nil(value) end) |> Enum.into(%{}) end # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # TYPES # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @doc ~S""" Creates a type that accepts any value. ## Examples iex> Z.any() iex> |> Z.parse("hello") {:ok, "hello"} iex> Z.any() iex> |> Z.parse(42) {:ok, 42} iex> Z.any() iex> |> Z.parse(%{foo: "bar"}) {:ok, %{foo: "bar"}} iex> Z.any() iex> |> Z.optional() iex> |> Z.parse(nil) {:ok, nil} Useful in maps where a field can accept any value: iex> Z.map(%{name: Z.string(), metadata: Z.any()}) iex> |> Z.parse(%{name: "Alice", metadata: %{role: "admin", tags: [1, 2, 3]}}) {:ok, %{name: "Alice", metadata: %{role: "admin", tags: [1, 2, 3]}}} Supports transform and refine effects: iex> Z.any() iex> |> Z.transform(&inspect/1) iex> |> Z.parse({:ok, 42}) {:ok, "{:ok, 42}"} It can be converted into json schema: iex> Z.any() iex> |> Z.describe("Arbitrary metadata.") iex> |> Z.json_schema() %{ "description" => "Arbitrary metadata." } """ defdelegate any, to: Zot.Type.Any, as: :new @doc ~S""" Creates an atom type. ## Examples iex> Z.atom() iex> |> Z.parse(:foo) {:ok, :foo} iex> Z.atom() iex> |> Z.parse("bar") iex> |> unwrap_issue_message() "expected type atom, got string" With `coerce: true`, it converts strings to existing atoms only: iex> Z.atom() iex> |> Z.parse("foo", coerce: true) {:ok, :foo} iex> Z.atom() iex> |> Z.parse("this_atom_does_not_exist", coerce: true) iex> |> unwrap_issue_message() "atom 'this_atom_does_not_exist' does not exist" With `coerce: :unsafe`, it converts any string to an atom: iex> Z.atom() iex> |> Z.parse("some_new_atom", coerce: :unsafe) {:ok, :some_new_atom} It can be converted into json schema: iex> Z.atom() iex> |> Z.describe("A status atom.") iex> |> Z.example(:active) iex> |> Z.json_schema() %{ "type" => "string", "description" => "A status atom.", "example" => "active" } """ defdelegate atom, to: Zot.Type.Atom, as: :new @doc ~S""" Creates a boolean type. ## Examples iex> Z.boolean() iex> |> Z.parse(true) {:ok, true} iex> Z.boolean() iex> |> Z.parse("yes") iex> |> unwrap_issue_message() "expected type boolean, got string" It can be coerced from boolean-like values: iex> Z.boolean() iex> |> Z.parse(1, coerce: true) {:ok, true} iex> Z.boolean() iex> |> Z.parse(0, coerce: true) {:ok, false} iex> Z.boolean() iex> |> Z.parse("true", coerce: true) {:ok, true} iex> Z.boolean() iex> |> Z.parse("false", coerce: true) {:ok, false} iex> Z.boolean() iex> |> Z.parse("on", coerce: true) {:ok, true} iex> Z.boolean() iex> |> Z.parse("off", coerce: true) {:ok, false} iex> Z.boolean() iex> |> Z.parse("enabled", coerce: true) {:ok, true} iex> Z.boolean() iex> |> Z.parse("disabled", coerce: true) {:ok, false} iex> Z.boolean() iex> |> Z.parse("yes", coerce: true) {:ok, true} iex> Z.boolean() iex> |> Z.parse("no", coerce: true) {:ok, false} It can be converted into json schema: iex> Z.boolean() iex> |> Z.describe("A boolean flag.") iex> |> Z.example(true) iex> |> Z.json_schema() %{ "type" => "boolean", "description" => "A boolean flag.", "example" => true } """ defdelegate boolean, to: Zot.Type.Boolean, as: :new @doc ~S""" Creates a date-time type. ## Examples iex> Z.date_time() iex> |> Z.parse(~U[2024-01-01T12:34:56Z]) {:ok, ~U[2024-01-01T12:34:56Z]} iex> Z.date_time() iex> |> Z.parse("foo") iex> |> unwrap_issue_message() "expected type DateTime, got string" You can enforce that the date-time is after a given date-time: iex> Z.date_time(min: ~U[2024-01-01 00:00:00Z]) iex> |> Z.parse(~U[2023-12-31 23:59:59Z]) iex> |> unwrap_issue_message() "must be after 2024-01-01T00:00:00Z" You can enforce that the date-time is before a given date-time: iex> Z.date_time(max: ~U[2023-12-31 23:59:59Z]) iex> |> Z.parse(~U[2024-01-01 00:00:00Z]) iex> |> unwrap_issue_message() "must be before 2023-12-31T23:59:59Z" It supports coercion from ISO8601 strings: iex> Z.date_time() iex> |> Z.parse("2024-01-01T12:34:56Z", coerce: true) {:ok, ~U[2024-01-01T12:34:56Z]} iex> Z.date_time() iex> |> Z.parse("Mon Jan 12 2026 11:16:30 GMT-0300 (Brasilia Standard Time)", coerce: true) iex> |> unwrap_issue_message() "must be a valid ISO8601 date-time string" It can be converted into json schema: iex> Z.date_time() iex> |> Z.describe("A timestamp.") iex> |> Z.example(~U[2026-01-10T10:23:45.123Z]) iex> |> Z.json_schema() %{ "type" => "string", "format" => "date-time", "description" => "A timestamp.", "example" => "2026-01-10T10:23:45.123Z" } """ defdelegate date_time(opts \\ []), to: Zot.Type.DateTime, as: :new @doc ~S""" Creates a decimal type. ## Examples iex> Z.decimal() iex> |> Z.parse(Decimal.new("123.45")) {:ok, Decimal.new("123.45")} You can enforce a minimum value: iex> Z.decimal(min: 10) iex> |> Z.parse(Decimal.new("9.99")) iex> |> unwrap_issue_message() "must be at least 10, got 9.99" You can enforce a maximum value: iex> Z.decimal(max: 9.99) iex> |> Z.parse(Decimal.new("10.00")) iex> |> unwrap_issue_message() "must be at most 9.99, got 10.0" It can be coerced from an int: iex> Z.decimal() iex> |> Z.parse(42, coerce: true) {:ok, Decimal.new("42")} It can be coerced from a float: iex> Z.decimal() iex> |> Z.parse(3.14, coerce: true) {:ok, Decimal.new("3.14")} It can be coerced from a string: iex> Z.decimal() iex> |> Z.parse("3.14", coerce: true) {:ok, Decimal.new("3.14")} It can be converted into json schema: iex> Z.decimal(min: 1.00, max: 100.00) iex> |> Z.describe("A monetary amount.") iex> |> Z.example(Decimal.new("19.99")) iex> |> Z.json_schema() %{ "type" => "number", "description" => "A monetary amount.", "example" => 19.99, "minimum" => 1.0, "maximum" => 100.0 } """ defdelegate decimal(opts \\ []), to: Zot.Type.Decimal, as: :new @doc ~S""" Creates a discriminated union of two or more map types. Unlike `union/1`, this provides more precise error reporting by using a discriminator field to determine which variant to parse. ## Examples Successful parsing with different variants: iex> Z.discriminated_union(:type, [ iex> Z.map(%{type: Z.literal("dog"), barks: Z.boolean()}), iex> Z.map(%{type: Z.literal("cat"), meows: Z.boolean()}) iex> ]) iex> |> Z.parse(%{type: "dog", barks: true}) {:ok, %{type: "dog", barks: true}} iex> Z.discriminated_union(:type, [ iex> Z.map(%{type: Z.literal("dog"), barks: Z.boolean()}), iex> Z.map(%{type: Z.literal("cat"), meows: Z.boolean()}) iex> ]) iex> |> Z.parse(%{type: "cat", meows: true}) {:ok, %{type: "cat", meows: true}} Works with string keys in the input: iex> Z.discriminated_union(:type, [ iex> Z.map(%{type: Z.literal("dog"), barks: Z.boolean()}), iex> Z.map(%{type: Z.literal("cat"), meows: Z.boolean()}) iex> ]) iex> |> Z.parse(%{"type" => "dog", "barks" => true}) {:ok, %{type: "dog", barks: true}} Error when discriminator value doesn't match any variant: iex> Z.discriminated_union(:type, [ iex> Z.map(%{type: Z.literal("dog"), barks: Z.boolean()}), iex> Z.map(%{type: Z.literal("cat"), meows: Z.boolean()}) iex> ]) iex> |> Z.parse(%{type: "bird", flies: true}) iex> |> unwrap_issue_message() "expected field type to be one of 'dog' or 'cat', got 'bird'" Error when input is not a map: iex> Z.discriminated_union(:type, [ iex> Z.map(%{type: Z.literal("dog"), barks: Z.boolean()}), iex> Z.map(%{type: Z.literal("cat"), meows: Z.boolean()}) iex> ]) iex> |> Z.parse("not a map") iex> |> unwrap_issue_message() "expected type map, got string" ArgumentError when discriminator field is missing from a map type: iex> try do iex> Z.discriminated_union(:kind, [ iex> Z.map(%{type: Z.literal("dog")}), iex> Z.map(%{type: Z.literal("cat")}) iex> ]) iex> rescue iex> e in ArgumentError -> e.message iex> end "the discriminator field :kind must exist in all map types" ArgumentError when inner types are not map types: iex> try do iex> Z.discriminated_union(:type, [Z.string(), Z.int()]) iex> rescue iex> e in ArgumentError -> e.message iex> end "discriminated union only accepts map types, got Zot.Type.String" ArgumentError when discriminator field is not a literal type: iex> try do iex> Z.discriminated_union(:type, [ iex> Z.map(%{type: Z.string(), name: Z.string()}), iex> Z.map(%{type: Z.string(), age: Z.int()}) iex> ]) iex> rescue iex> e in ArgumentError -> e.message iex> end "the discriminator field :type must be a literal type, got Zot.Type.String" It can be converted into json schema: iex> Z.discriminated_union(:type, [ iex> Z.map(%{type: Z.literal("dog"), barks: Z.boolean()}), iex> Z.map(%{type: Z.literal("cat"), meows: Z.boolean()}) iex> ]) iex> |> Z.json_schema() %{ "oneOf" => [ %{ "type" => "object", "additionalProperties" => true, "properties" => %{ "type" => %{"const" => "dog"}, "barks" => %{"type" => "boolean"} }, "required" => ["type", "barks"] }, %{ "type" => "object", "additionalProperties" => true, "properties" => %{ "type" => %{"const" => "cat"}, "meows" => %{"type" => "boolean"} }, "required" => ["type", "meows"] } ], "discriminator" => %{ "propertyName" => "type" } } """ def discriminated_union(discriminator, types) when is_atom(discriminator) and is_list(types), do: Zot.Type.DiscriminatedUnion.new(discriminator: discriminator, inner_types: types) @doc ~S""" Creates an email type. ## Examples iex> Z.email() iex> |> Z.parse("foo@zot.dev") {:ok, "foo@zot.dev"} You can optionally specify a ruleset for validation: iex> Z.email(ruleset: :html5) iex> |> Z.parse("invalid-email") iex> |> unwrap_issue_message() "is invalid" iex> Z.email(ruleset: :ref5322) iex> |> Z.parse("invalid-email") iex> |> unwrap_issue_message() "is invalid" iex> Z.email(ruleset: :unicode) iex> |> Z.parse("invalid-email") iex> |> unwrap_issue_message() "is invalid" It can be converted into json schema: iex> Z.email() iex> |> Z.describe("A user's email address.") iex> |> Z.example("foo@zot.dev") iex> |> Z.json_schema() %{ "type" => "string", "format" => "email", "description" => "A user's email address.", "example" => "foo@zot.dev" } """ defdelegate email(opts \\ []), to: Zot.Type.Email, as: :new @doc ~S""" Creates an enum type. ## Examples Values can be all atoms: iex> Z.enum([:red, :green, :blue]) iex> |> Z.parse(:green) {:ok, :green} iex> Z.enum([:red, :green, :blue]) iex> |> Z.parse(:yellow) iex> |> unwrap_issue_message() "must be :red, :green or :blue, got :yellow" Or they can be all strings: iex> Z.enum(["small", "medium", "large"]) iex> |> Z.parse("medium") {:ok, "medium"} iex> Z.enum(["small", "medium", "large"]) iex> |> Z.parse("extra large") iex> |> unwrap_issue_message() "must be 'small', 'medium' or 'large', got 'extra large'" It can be converted to json schema: iex> Z.enum([:red, :green, :blue]) iex> |> Z.describe("A color.") iex> |> Z.example(:green) iex> |> Z.json_schema() %{ "type" => "string", "enum" => ["red", "green", "blue"], "description" => "A color.", "example" => "green" } """ def enum(values) when is_list(values), do: Zot.Type.Enum.new(values: values) @doc ~S""" Creates a float type. ## Examples iex> Z.float() iex> |> Z.parse(3.14) {:ok, 3.14} You can enforce a minimum value: iex> Z.float(min: 1.0) iex> |> Z.parse(0.99) iex> |> unwrap_issue_message() "must be at least 1.0, got 0.99" You can enforce a maximum value: iex> Z.float(max: 10.0) iex> |> Z.parse(10.01) iex> |> unwrap_issue_message() "must be at most 10.0, got 10.01" It can be coerced from an int: iex> Z.float() iex> |> Z.parse(42, coerce: true) {:ok, 42.0} It can be coerced from Decimal: iex> Z.float() iex> |> Z.parse(Decimal.new("3.14"), coerce: true) {:ok, 3.14} It can be coerced from a string: iex> Z.float() iex> |> Z.parse("3.14", coerce: true) {:ok, 3.14} It can be converted into json schema: iex> Z.float(min: 0.0, max: 1.0) iex> |> Z.describe("A percentage.") iex> |> Z.example(0.425) iex> |> Z.json_schema() %{ "type" => "number", "description" => "A percentage.", "example" => 0.425, "minimum" => 0.0, "maximum" => 1.0 } """ defdelegate float(opts \\ []), to: Zot.Type.Float, as: :new @doc ~S""" Creates a integer type. ## Examples iex> Z.int() iex> |> Z.parse(42) {:ok, 42} You can enforce a minimum value: iex> Z.int(min: 18) iex> |> Z.parse(16) iex> |> unwrap_issue_message() "must be at least 18, got 16" You can enforce a maximum value: iex> Z.int(max: 18) iex> |> Z.parse(33) iex> |> unwrap_issue_message() "must be at most 18, got 33" It can be coerced from an float (rounded): iex> Z.int() iex> |> Z.parse(3.14, coerce: true) {:ok, 3} It can be coerced from Decimal (rounded): iex> Z.int() iex> |> Z.parse(Decimal.new("3.14"), coerce: true) {:ok, 3} It can be coerced from a string: iex> Z.int() iex> |> Z.parse("42", coerce: true) {:ok, 42} It can be converted into json schema: iex> Z.int(min: 0, max: 100) iex> |> Z.describe("A percentage.") iex> |> Z.example(42) iex> |> Z.json_schema() %{ "type" => "integer", "description" => "A percentage.", "example" => 42, "minimum" => 0, "maximum" => 100 } """ defdelegate int(opts \\ []), to: Zot.Type.Integer, as: :new @doc ~S""" Creates a list type. ## Examples iex> Z.string() iex> |> Z.list() iex> |> Z.parse(["hello", "world"]) {:ok, ["hello", "world"]} You can enforce a minimum length: iex> Z.string() iex> |> Z.list(min: 3) iex> |> Z.parse(["one", "two"]) iex> |> unwrap_issue_message() "must have at least 3 items, got 2" You can enforce a maximum length: iex> Z.string() iex> |> Z.list(max: 2) iex> |> Z.parse(["one", "two", "three"]) iex> |> unwrap_issue_message() "must have at most 2 items, got 3" It can be converted into json schema: iex> Z.string() iex> |> Z.list(min: 1, max: 5) iex> |> Z.describe("A list of tags.") iex> |> Z.example(["elixir", "zot"]) iex> |> Z.json_schema() %{ "type" => "array", "items" => %{ "type" => "string" }, "description" => "A list of tags.", "minItems" => 1, "maxItems" => 5 } """ def list(type(_) = inner_type, opts \\ []) when is_list(opts), do: Zot.Type.List.new([{:inner_type, inner_type} | opts]) @doc ~S""" Creates a literal type. ## Examples It can be a boolean: iex> Z.literal(true) iex> |> Z.parse(true) {:ok, true} iex> Z.literal(true) iex> |> Z.parse(false) iex> |> unwrap_issue_message() "must be true, got false" iex> Z.literal(true) iex> |> Z.parse("enabled", coerce: true) {:ok, true} It can be an integer: iex> Z.literal(42) iex> |> Z.parse(42) {:ok, 42} iex> Z.literal(42) iex> |> Z.parse(43) iex> |> unwrap_issue_message() "must be 42, got 43" iex> Z.literal(42) iex> |> Z.parse("42", coerce: true) {:ok, 42} It can be a float: iex> Z.literal(3.14) iex> |> Z.parse(3.14) {:ok, 3.14} iex> Z.literal(3.14) iex> |> Z.parse(3.13) iex> |> unwrap_issue_message() "must be 3.14, got 3.13" iex> Z.literal(3.14) iex> |> Z.parse("3.14", coerce: true) {:ok, 3.14} It can be a string: iex> Z.literal("foo") iex> |> Z.parse("foo") {:ok, "foo"} iex> Z.literal("foo") iex> |> Z.parse("bar") iex> |> unwrap_issue_message() "must be 'foo', got 'bar'" It can be an atom: iex> Z.literal(:admin) iex> |> Z.parse(:admin) {:ok, :admin} iex> Z.literal(:admin) iex> |> Z.parse(:user) iex> |> unwrap_issue_message() "must be :admin, got :user" iex> Z.literal(:admin) iex> |> Z.parse("admin", coerce: true) {:ok, :admin} iex> Z.literal(:admin) iex> |> Z.parse("user", coerce: true) iex> |> unwrap_issue_message() "must be :admin, got 'user'" It can be converted into json schema: iex> Z.literal("active") iex> |> Z.describe("Lorem ipsum.") iex> |> Z.json_schema() %{ "const" => "active", "description" => "Lorem ipsum.", } """ def literal(value), do: Zot.Type.Literal.new(value: value) @doc ~S""" Creates a map type where unknown fields are stripped out. ## Examples iex> Z.map(%{name: Z.string(), age: Z.int(min: 18)}) iex> |> Z.parse(%{name: "Alice", age: 18, email: "alice@wonder.land"}) {:ok, %{name: "Alice", age: 18}} iex> {:error, [issue]} = iex> Z.map(%{name: Z.string(), age: Z.int(min: 18)}) iex> |> Z.parse(%{name: "Alice", age: 16, email: "alice@wonder.land"}) iex> iex> assert issue.path == [:age] iex> assert Exception.message(issue) == "must be at least 18, got 16" It can be converted into json schema: iex> Z.map(%{name: Z.string(), age: Z.int(min: 0)}) iex> |> Z.describe("A person's profile.") iex> |> Z.example(%{name: "Bob", age: 30}) iex> |> Z.json_schema() %{ "type" => "object", "description" => "A person's profile.", "example" => %{name: "Bob", age: 30}, "properties" => %{ "name" => %{ "type" => "string" }, "age" => %{ "type" => "integer", "minimum" => 0 } }, "required" => ["name", "age"], "additionalProperties" => true } """ def map(shape) when is_non_struct_map(shape) when is_list(shape), do: Zot.Type.Map.new(mode: :strip, shape: Enum.into(shape, %{})) @doc ~S""" Merges two map types into a new map type. The second map's fields override the first on conflicts. The resulting map is strict if either input map is strict. Note that `required`, `default`, `description`, `example`, and `effects` are lost when merging two maps. Use the appropriate modifiers after merging to set these fields. ## Examples iex> map1 = Z.map(%{name: Z.string()}) iex> map2 = Z.map(%{age: Z.int()}) iex> Z.merge(map1, map2) iex> |> Z.parse(%{name: "Alice", age: 30}) {:ok, %{name: "Alice", age: 30}} iex> map1 = Z.map(%{name: Z.string()}) iex> map2 = Z.map(%{name: Z.int()}) iex> Z.merge(map1, map2) iex> |> Z.parse(%{name: 42}) {:ok, %{name: 42}} iex> map1 = Z.strict_map(%{name: Z.string()}) iex> map2 = Z.map(%{age: Z.int()}) iex> Z.merge(map1, map2) iex> |> Z.parse(%{name: "Alice", age: 30, extra: "field"}) iex> |> unwrap_issue_message() "unknown field" """ def merge(%Zot.Type.Map{} = a, %Zot.Type.Map{} = b) do mode = if :strict in [a.mode, b.mode], do: :strict, else: :strip shape = Map.merge(a.shape, b.shape) Zot.Type.Map.new(mode: mode, shape: shape) end @doc ~S""" Creates a number type (union of integer and float types). ## Examples iex> Z.number() iex> |> Z.parse(3.14) {:ok, 3.14} iex> Z.number() iex> |> Z.parse(42) {:ok, 42} It can be converted into json schema: iex> Z.number(min: 0.5, max: 100) iex> |> Z.describe("A percentage.") iex> |> Z.example(42) iex> |> Z.json_schema() %{ "type" => "number", "description" => "A percentage.", "example" => 42, "minimum" => 0.5, "maximum" => 100 } See `float/1` and `int/1` for more examples. """ defdelegate number(opts \\ []), to: Zot.Type.Number, as: :new @doc ~S""" Creates a non-empty string type that trims whitespace. This is an alias for `string(trim: true, min: 1)`. ## Examples iex> Z.non_empty_string() iex> |> Z.parse("hello") {:ok, "hello"} iex> Z.non_empty_string() iex> |> Z.parse(" ") iex> |> unwrap_issue_message() "must be at least 1 characters long, got 0" iex> Z.non_empty_string() iex> |> Z.parse("") iex> |> unwrap_issue_message() "must be at least 1 characters long, got 0" Whitespace is trimmed before validation: iex> Z.non_empty_string() iex> |> Z.parse(" hello ") {:ok, "hello"} """ def non_empty_string, do: string(trim: true, min: 1) @doc ~S""" Creates a numeric string type. ## Examples iex> Z.numeric() iex> |> Z.parse("123456") {:ok, "123456"} iex> Z.numeric() iex> |> Z.parse("123abc") iex> |> unwrap_issue_message() "must contain only 0-9 digits" You can enforce a minimum length: iex> Z.numeric(min: 5) iex> |> Z.parse("1234") iex> |> unwrap_issue_message() "must be at least 5 characters long, got 4" You can enforce a maximum length: iex> Z.numeric(max: 10) iex> |> Z.parse("12345678901") iex> |> unwrap_issue_message() "must be at most 10 characters long, got 11" It can be converted into json schema: iex> Z.numeric(min: 3, max: 8) iex> |> Z.describe("A numeric code.") iex> |> Z.example("123456") iex> |> Z.json_schema() %{ "type" => "string", "description" => "A numeric code.", "example" => "123456", "pattern" => "^[0-9]+$", "minLength" => 3, "maxLength" => 8 } """ defdelegate numeric(opts \\ []), to: Zot.Type.Numeric, as: :new @doc ~S""" Creates a phone number type. ## Examples iex> Z.phone() iex> |> Z.parse("+5511987654321") {:ok, "+5511987654321"} iex> Z.phone() iex> |> Z.parse("5511987654321") {:ok, "5511987654321"} You can define the behavior for the leading plus sign, where the options are: - `:always` - if absent, adds it to the output; - `:forbid` - if present, results in an issue; - `:keep` (default) - if present, keeps it; and - `:require` - if absent, results in an issue. iex> Z.phone(leading_plus_sign: :always) iex> |> Z.parse("5511987654321") {:ok, "+5511987654321"} iex> Z.phone(leading_plus_sign: :forbid) iex> |> Z.parse("+5511987654321") iex> |> unwrap_issue_message() "must not start with a leading plus sign (+)" iex> Z.phone(leading_plus_sign: :keep) iex> |> Z.parse("+5511987654321") {:ok, "+5511987654321"} iex> Z.phone(leading_plus_sign: :keep) iex> |> Z.parse("5511987654321") {:ok, "5511987654321"} iex> Z.phone(leading_plus_sign: :require) iex> |> Z.parse("5511987654321") iex> |> unwrap_issue_message() "must start with a leading plus sign (+)" It can be converted into json schema: iex> Z.phone(leading_plus_sign: :always) iex> |> Z.describe("A phone number.") iex> |> Z.example("+5511987654321") iex> |> Z.json_schema() %{ "description" => "A phone number.", "example" => "+5511987654321", "format" => "phone", "maxLength" => 16, "minLength" => 9, "pattern" => "^\\+[0-9]{8,15}$", "type" => "string" } """ defdelegate phone(opts \\ []), to: Zot.Type.Phone, as: :new @doc ~S""" Creates a record type where keys are non-empty strings. ## Examples iex> Z.record(Z.int()) iex> |> Z.parse(%{"a" => 1, "b" => 2}) {:ok, %{"a" => 1, "b" => 2}} iex> {:error, [issue]} = iex> Z.record(Z.float()) iex> |> Z.parse(%{"a" => 3.14, "b" => "not a float"}) iex> iex> assert issue.path == ["b"] iex> assert Exception.message(issue) == "expected type float, got string" """ def record(type(_) = values_type), do: Zot.Type.Record.new(keys_type: string(trim: true, min: 1), values_type: values_type) @doc ~S""" Creates a map type where unknown fields cause an issue. ## Examples iex> Z.strict_map(%{name: Z.string(), age: Z.int(min: 18)}) iex> |> Z.parse(%{name: "Alice", age: 18}) {:ok, %{name: "Alice", age: 18}} iex> {:error, [issue]} = iex> Z.strict_map(%{name: Z.string(), age: Z.int(min: 18)}) iex> |> Z.parse(%{name: "Alice", age: 18, email: "alice@wonder.land"}) iex> iex> assert issue.path == ["email"] iex> assert Exception.message(issue) == "unknown field" It can be converted into json schema: iex> Z.strict_map(%{name: Z.string(), age: Z.int(min: 0)}) iex> |> Z.describe("A person's profile.") iex> |> Z.example(%{name: "Bob", age: 30}) iex> |> Z.json_schema() %{ "type" => "object", "description" => "A person's profile.", "example" => %{name: "Bob", age: 30}, "properties" => %{ "name" => %{ "type" => "string" }, "age" => %{ "type" => "integer", "minimum" => 0 } }, "required" => ["name", "age"], "additionalProperties" => false } """ def strict_map(shape) when is_non_struct_map(shape) when is_list(shape), do: Zot.Type.Map.new(mode: :strict, shape: Enum.into(shape, %{})) @doc ~S""" Creates a string type. ## Examples iex> Z.string() iex> |> Z.parse("hello world") {:ok, "hello world"} Can enforce that the string contains a given substring: iex> Z.string(contains: "foo") iex> |> Z.parse("bar baz") iex> |> unwrap_issue_message() "must contain 'foo'" iex> Z.string() iex> |> Z.contains("foo") iex> |> Z.parse("bar baz") iex> |> unwrap_issue_message() "must contain 'foo'" Can enforce a string length: iex> Z.string(length: 5) iex> |> Z.parse("hey") iex> |> unwrap_issue_message() "must be 5 characters long, got 3" iex> Z.string() iex> |> Z.length(5) iex> |> Z.parse("hey") iex> |> unwrap_issue_message() "must be 5 characters long, got 3" Can enforce a minimum string length: iex> Z.string(min: 3) iex> |> Z.parse("hi") iex> |> unwrap_issue_message() "must be at least 3 characters long, got 2" iex> Z.string() iex> |> Z.min(3) iex> |> Z.parse("hi") iex> |> unwrap_issue_message() "must be at least 3 characters long, got 2" Can enforce a maximum string length: iex> Z.string(max: 10) iex> |> Z.parse("this is a very long string") iex> |> unwrap_issue_message() "must be at most 10 characters long, got 26" iex> Z.string() iex> |> Z.max(10) iex> |> Z.parse("this is a very long string") iex> |> unwrap_issue_message() "must be at most 10 characters long, got 26" Can enforce that the string starts with a given substring: iex> Z.string(starts_with: "Hello") iex> |> Z.parse("World, Hello!") iex> |> unwrap_issue_message() "must start with 'Hello'" iex> Z.string() iex> |> Z.starts_with("Hello") iex> |> Z.parse("World, Hello!") iex> |> unwrap_issue_message() "must start with 'Hello'" Can enforce that the string ends with a given substring: iex> Z.string(ends_with: "World!") iex> |> Z.parse("World, Hello!") iex> |> unwrap_issue_message() "must end with 'World!'" iex> Z.string() iex> |> Z.ends_with("World!") iex> |> Z.parse("World, Hello!") iex> |> unwrap_issue_message() "must end with 'World!'" Can enforce that the string matches a given regex: iex> Z.string(regex: ~r/^hello/) iex> |> Z.parse("world hello") iex> |> unwrap_issue_message() "must match pattern /^hello/" iex> Z.string() iex> |> Z.regex(~r/^hello/) iex> |> Z.parse("world hello") iex> |> unwrap_issue_message() "must match pattern /^hello/" You can specify for the string to be trimmed before validation:DSS iex> Z.string(trim: true, starts_with: "Hello") iex> |> Z.parse(" Hello, World!") {:ok, "Hello, World!"} iex> Z.string() iex> |> Z.trim() iex> |> Z.starts_with("Hello") iex> |> Z.parse(" Hello, World!") {:ok, "Hello, World!"} It can be converted into json schema: iex> Z.string(starts_with: "u_", length: 28) iex> |> Z.describe("A user id.") iex> |> Z.example("u_12345678901234567890123456") iex> |> Z.json_schema() %{ "type" => "string", "description" => "A user id.", "example" => "u_12345678901234567890123456", "minLength" => 28, "maxLength" => 28 } """ defdelegate string(opts \\ []), to: Zot.Type.String, as: :new @doc ~S""" Creates a union of two or more types. ## Examples iex> Z.union([Z.string(), Z.int()]) iex> |> Z.parse("hello") {:ok, "hello"} iex> Z.union([Z.string(), Z.int()]) iex> |> Z.parse(42) {:ok, 42} Beware that only one of the types will have its error reported: iex> Z.union([Z.string(), Z.int()]) iex> |> Z.parse(3.14) iex> |> unwrap_issue_message() "expected type integer, got float" See `discriminated_union/2` which provides more precise error reporting at the cost of requiring a discriminator field. It can be converted into json schema: iex> Z.union([Z.string(), Z.int()]) iex> |> Z.json_schema() %{ "anyOf" => [ %{ "type" => "string" }, %{ "type" => "integer" } ] } """ def union(types), do: Zot.Type.Union.new(inner_types: types) @doc ~S""" Creates a URI string type. ## Examples iex> Z.uri() iex> |> Z.parse("https://zot.dev") {:ok, "https://zot.dev"} iex> Z.uri() iex> |> Z.parse("not a uri") iex> |> unwrap_issue_message() "is invalid" You can enforce a limited set of allowed schemes: iex> Z.uri(allowed_schemes: ["http", "https"]) iex> |> Z.parse("ftp://zot.dev") iex> |> unwrap_issue_message() "scheme must be 'http' or 'https', got 'ftp'" You can specify whether query strings are forbidden, should be trimmed out from the URI, or kept (default): iex> Z.uri(query_string: :keep) iex> |> Z.parse("https://zot.dev?page=1") {:ok, "https://zot.dev?page=1"} iex> Z.uri(query_string: :forbid) iex> |> Z.parse("https://zot.dev?page=1") iex> |> unwrap_issue_message() "query string is not allowed" iex> Z.uri(query_string: :trim) iex> |> Z.parse("https://zot.dev?page=1") {:ok, "https://zot.dev"} You can specify whether trailing slashes should always be present, should be kept if present (default), or should be trimmed out: iex> Z.uri(trailing_slash: :always) iex> |> Z.parse("https://zot.dev/path") {:ok, "https://zot.dev/path/"} iex> Z.uri(trailing_slash: :always) iex> |> Z.parse("https://zot.dev/path/") {:ok, "https://zot.dev/path/"} iex> Z.uri(trailing_slash: :trim) iex> |> Z.parse("https://zot.dev/path/") {:ok, "https://zot.dev/path"} iex> Z.uri(trailing_slash: :keep) iex> |> Z.parse("https://zot.dev/path") {:ok, "https://zot.dev/path"} iex> Z.uri(trailing_slash: :keep) iex> |> Z.parse("https://zot.dev/path/") {:ok, "https://zot.dev/path/"} iex> Z.uri(trailing_slash: :trim) iex> |> Z.parse("https://zot.dev/path") {:ok, "https://zot.dev/path"} """ defdelegate uri(opts \\ []), to: Zot.Type.URI, as: :new @doc ~S""" Creates a UUID type. ## Examples iex> Z.uuid() iex> |> Z.parse("550e8400-e29b-41d4-a716-446655440000") {:ok, "550e8400-e29b-41d4-a716-446655440000"} iex> Z.uuid() iex> |> Z.parse("not-a-uuid") iex> |> unwrap_issue_message() "is invalid" You can specify the UUID version to enforce: iex> Z.uuid(:v1) iex> |> Z.parse("550e8400-e29b-21d4-a716-446655440000") iex> |> unwrap_issue_message() "expected a uuid v1, got v2" iex> Z.uuid(:v2) iex> |> Z.parse("550e8400-e29b-31d4-a716-446655440000") iex> |> unwrap_issue_message() "expected a uuid v2, got v3" iex> Z.uuid(:v3) iex> |> Z.parse("550e8400-e29b-41d4-a716-446655440000") iex> |> unwrap_issue_message() "expected a uuid v3, got v4" iex> Z.uuid(:v4) iex> |> Z.parse("550e8400-e29b-51d4-a716-446655440000") iex> |> unwrap_issue_message() "expected a uuid v4, got v5" iex> Z.uuid(:v5) iex> |> Z.parse("550e8400-e29b-61d4-a716-446655440000") iex> |> unwrap_issue_message() "expected a uuid v5, got v6" iex> Z.uuid(:v6) iex> |> Z.parse("550e8400-e29b-71d4-a716-446655440000") iex> |> unwrap_issue_message() "expected a uuid v6, got v7" iex> Z.uuid(:v7) iex> |> Z.parse("550e8400-e29b-81d4-a716-446655440000") iex> |> unwrap_issue_message() "expected a uuid v7, got v8" iex> Z.uuid(:v8) iex> |> Z.parse("550e8400-e29b-11d4-a716-446655440000") iex> |> unwrap_issue_message() "expected a uuid v8, got v1" It can be converted into json schema: iex> Z.uuid(:v4) iex> |> Z.describe("A universally unique identifier.") iex> |> Z.example("550e8400-e29b-41d4-a716-446655440000") iex> |> Z.json_schema() %{ "type" => "string", "format" => "uuid", "description" => "A universally unique identifier.", "example" => "550e8400-e29b-41d4-a716-446655440000" } """ def uuid(version \\ :any), do: Zot.Type.UUID.new(version: version) # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # MODIFIERS # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @doc ~S""" Constraint phone numbers to a limited set of country codes. See `phone/1` for more details. """ defdelegate allowed_country_codes(type, value, opts \\ []), to: Zot.Type.Phone @doc ~S""" Enforces that the URI has one of the given allowed schemes. See `uri/1` for more details. """ defdelegate allowed_schemes(type, value, opts \\ []), to: Zot.Type.URI @doc ~S""" Enforces that the string contains the given substring. """ def contains(type, value, opts \\ []) def contains(%Zot.Type.String{} = type, value, opts), do: Zot.Type.String.contains(type, value, opts) @doc ~S""" Sets the field as not-required and provides a default value. """ def default(type(_) = type, value), do: %{type | required: false, default: value} @doc ~S""" Attaches a description to the type, for use in JSON Schema. """ def describe(type(_) = type, desc) when is_nil(desc) when is_binary(desc) and byte_size(desc) > 0, do: %{type | description: desc} @doc ~S""" Enforces that the string ends with the given substring. """ def ends_with(type, value, opts \\ []) def ends_with(%Zot.Type.String{} = type, value, opts), do: Zot.Type.String.ends_with(type, value, opts) @doc ~S""" Attaches an example value to the type, for use in JSON Schema. """ def example(type(_) = type, example), do: %{type | example: example} @doc ~S""" Defines the behavior for leading plus signs in phone numbers. See `phone/1` for more details. """ defdelegate leading_plus_sign(type, value), to: Zot.Type.Phone @doc ~S""" Enforces that the string has the given length. """ def length(type, value, opts \\ []) def length(%Zot.Type.String{} = type, value, opts), do: Zot.Type.String.length(type, value, opts) @doc ~S""" Defines the behavior regarding query strings in URIs. See `uri/1` for more details. """ defdelegate query_string(type, value, opts \\ []), to: Zot.Type.URI @doc ~S""" Enforces a maximum value for the given type. """ def max(type, value, opts \\ []) def max(%Zot.Type.DateTime{} = type, value, opts), do: Zot.Type.DateTime.max(type, value, opts) def max(%Zot.Type.Decimal{} = type, value, opts), do: Zot.Type.Decimal.max(type, value, opts) def max(%Zot.Type.Float{} = type, value, opts), do: Zot.Type.Float.max(type, value, opts) def max(%Zot.Type.Integer{} = type, value, opts), do: Zot.Type.Integer.max(type, value, opts) def max(%Zot.Type.List{} = type, value, opts), do: Zot.Type.List.max(type, value, opts) def max(%Zot.Type.Number{} = type, value, opts), do: Zot.Type.Number.max(type, value, opts) def max(%Zot.Type.Numeric{} = type, value, opts), do: Zot.Type.Numeric.max(type, value, opts) def max(%Zot.Type.String{} = type, value, opts), do: Zot.Type.String.max(type, value, opts) @doc ~S""" Enforces a minimum value for the given type. """ def min(type, value, opts \\ []) def min(%Zot.Type.DateTime{} = type, value, opts), do: Zot.Type.DateTime.min(type, value, opts) def min(%Zot.Type.Decimal{} = type, value, opts), do: Zot.Type.Decimal.min(type, value, opts) def min(%Zot.Type.Float{} = type, value, opts), do: Zot.Type.Float.min(type, value, opts) def min(%Zot.Type.Integer{} = type, value, opts), do: Zot.Type.Integer.min(type, value, opts) def min(%Zot.Type.List{} = type, value, opts), do: Zot.Type.List.min(type, value, opts) def min(%Zot.Type.Number{} = type, value, opts), do: Zot.Type.Number.min(type, value, opts) def min(%Zot.Type.Numeric{} = type, value, opts), do: Zot.Type.Numeric.min(type, value, opts) def min(%Zot.Type.String{} = type, value, opts), do: Zot.Type.String.min(type, value, opts) @doc ~S""" Sets the field as not required (nullable). """ def optional(type(_) = type), do: %{type | required: false} @doc ~S""" Makes all fields optional. Optionally drops all nil fields from the resulting map, after successfully parsed and validate. ## Examples iex> Z.strict_map(%{name: Z.string(), age: Z.int()}) iex> |> Z.partial() iex> |> Z.parse(%{name: "Alice"}) {:ok, %{name: "Alice", age: nil}} iex> Z.strict_map(%{name: Z.string(), age: Z.int()}) iex> |> Z.partial() iex> |> Z.parse(%{}) {:ok, %{name: nil, age: nil}} You can optionally compact the resulting map (drop nil fields): iex> Z.strict_map(%{name: Z.string(), age: Z.int()}) iex> |> Z.partial(compact: true) iex> |> Z.parse(%{name: "Alice"}) {:ok, %{name: "Alice"}} It can be converted into json schema: iex> Z.strict_map(%{name: Z.string(), age: Z.int()}) iex> |> Z.partial() iex> |> Z.describe("A person's profile.") iex> |> Z.example(%{"name" => "Bob", "age" => 18}) iex> |> Z.json_schema() %{ "type" => "object", "description" => "A person's profile.", "example" => %{"name" => "Bob", "age" => 18}, "properties" => %{ "name" => %{ "type" => ["string", "null"] }, "age" => %{ "type" => ["integer", "null"] } }, "required" => [], "additionalProperties" => false } """ def partial(%Zot.Type.Map{} = type, opts \\ []) do shape = type.shape |> Enum.map(fn {key, t} -> {key, optional(t)} end) |> Enum.into(%{}) case Keyword.get(opts, :compact, false) do true -> transform(%{type | shape: shape}, {__MODULE__, :__drop_nil_fields__, []}) false -> %{type | shape: shape} end end @doc ~S""" Alias for `partial/2` with option `compact: true`. """ def partial_compact(type), do: partial(type, compact: true) @doc ~S""" Adds a custom refinement to the given type's effects pipeline, which is executed after the type is successfully parsed and validated. ## Examples iex> Z.int() iex> |> Z.refine(& &1 >= 18) iex> |> Z.parse(16) iex> |> unwrap_issue_message() "is invalid" You can optionally provide a custom error message: iex> Z.int() iex> |> Z.refine(& &1 >= 18, error: "must be greater than or equal to 18") iex> |> Z.parse(16) iex> |> unwrap_issue_message() "must be greater than or equal to 18" The error message may include the actual value: iex> Z.int() iex> |> Z.refine(& &1 >= 18, error: "must be greater than or equal to 18, got %{actual}") iex> |> Z.parse(16) iex> |> unwrap_issue_message() "must be greater than or equal to 18, got 16" """ @opts error: "is invalid" def refine(type(_) = type, fun, opts \\ []) when is_mfa(fun) when is_function(fun, 1) when is_function(fun, 2), do: %{type | effects: type.effects ++ [{:refine, Zot.Parameterized.new(fun, @opts, opts)}]} @doc ~S""" Enforces that the string matches the given regex. """ def regex(type, value, opts \\ []) def regex(%Zot.Type.String{} = type, value, opts), do: Zot.Type.String.regex(type, value, opts) @doc ~S""" Enforces that the string starts with the given substring. """ def starts_with(type, value, opts \\ []) def starts_with(%Zot.Type.String{} = type, value, opts), do: Zot.Type.String.starts_with(type, value, opts) @doc ~S""" Defines the behavior regarding trailing slashes in URIs. See `uri/1` for more details. """ defdelegate trailing_slash(type, value), to: Zot.Type.URI @doc ~S""" Adds a transformation to the given type's effects pipeline, which is executed after the type is successfully parsed and validated. ## Examples iex> Z.int() iex> |> Z.transform(&Decimal.new/1) iex> |> Z.parse(42) {:ok, Decimal.new(42)} """ def transform(type(_) = type, fun) when is_mfa(fun) when is_function(fun, 1), do: %{type | effects: type.effects ++ [{:transform, fun}]} @doc ~S""" Trims whitespace from the beginning and end of the string before validation. """ def trim(%Zot.Type.String{} = type, value \\ true), do: Zot.Type.String.trim(type, value) @doc ~S""" Enforces the UUID version for the given UUID type. See `uuid/1` for more details. """ defdelegate version(type, value), to: Zot.Type.UUID # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # CALLBACKS # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @doc false def __drop_nil_fields__(map) when is_non_struct_map(map) do map |> Enum.reject(fn {_, value} -> is_nil(value) end) |> Enum.into(%{}) end end