Backplane.Schema (backplane_schema v1.10.60)

Copy Markdown

Composable Elixir schema validation, transformation, and JSON Schema conversion.

Summary

Functions

Checks if the given data conforms to the specified schema.

Decodes wire data against a schema.

Defines a schema with a given name and schema definition.

Encodes data against a schema, producing its wire representation.

Drops the given top-level keys from a map schema.

Decodes a JSON Schema (Draft 7) map into a Backplane.Schema schema.

Checks if the given data is an enumerable, specifically a map or a list.

Checks if the given data is a numeric value, specifically a integer or a float.

Checks if the given type as an atom is a numeric (integer or float).

Deep merges two map schemas into a new one.

Helper function to put a value into an enum, handling not only maps and keyword lists but also structs.

Keeps only the given top-level keys of a map schema.

Converts a Backplane.Schema schema into a JSON Schema (Draft 7) map.

Validates a given data map against a schema with options.

Validates a schema definition to ensure it adheres to the expected structure and types.

Depth-first rewrite of a schema tree.

Types

coerce_def()

@type coerce_def() ::
  {:coerce, schema_def()}
  | {:coerce, schema_def(), keyword()}
  | {:coerce, coerce_source(), schema_def()}
  | {:coerce, coerce_source(), schema_def(), keyword()}

coerce_source()

@type coerce_source() ::
  :string
  | (term() -> {:ok, term()} | :error)
  | {module(), atom()}
  | {module(), atom(), [term()]}

cond_def()

@type cond_def() ::
  {:cond, condition :: (term() -> boolean()), true_branch :: schema_def(),
   else_branch :: schema_def()}
  | {:cond, condition :: (current :: term(), root :: term() -> boolean()),
     true_branch :: schema_def(), else_branch :: schema_def()}

custom_def()

@type custom_def() ::
  {:custom, custom_validation()}
  | {:custom, {module(), atom()}}
  | {:custom, {module(), atom(), [term()]}}

custom_validation()

@type custom_validation() ::
  (value :: term() -> validation_result())
  | (value :: term(), root :: term() -> validation_result())

default_def()

@type default_def() ::
  {schema_def(), {:default, term()}}
  | {schema_def(), {:default, (-> term())}}
  | {schema_def(), {:default, {module(), atom()}}}

dependent_def()

@type dependent_def() ::
  {:dependent, field :: atom(), validation(), type :: schema_def()}
  | {:dependent,
     (term() ->
        {:ok, schema_def() | nil}
        | {:error, template :: String.t(), context :: map() | keyword()})}
  | {:dependent,
     (current :: term(), root :: term() ->
        {:ok, schema_def() | nil}
        | {:error, template :: String.t(), context :: map() | keyword()})}
  | {:dependent, {module(), fun :: atom()}}
  | {:dependent, {module(), fun :: atom(), args :: list()}}

encode_def()

@type encode_def() ::
  {schema_def(), {:encode, (term() -> term())}}
  | {schema_def(), {:encode, {module(), atom()}}}
  | {schema_def(), {:encode, {module(), atom(), [term()]}}}

explicit_schema_def()

@type explicit_schema_def() ::
  {:schema, schema()}
  | {:schema, map_schema(), {:additional_keys, schema_def()}}

float_def()

@type float_def() ::
  :float | {:float, numeric_option(float()) | [numeric_option(float())]}

int_def()

@type int_def() ::
  :integer | {:integer, numeric_option(integer()) | [numeric_option(integer())]}

literal()

@type literal() :: integer() | float() | atom() | String.t() | boolean()

map_schema()

@type map_schema() :: %{required(String.t() | atom()) => schema_def()}

numeric_option(type)

@type numeric_option(type) ::
  {:eq, type}
  | {:neq, type}
  | {:lt, type}
  | {:lte, type}
  | {:gt, type}
  | {:gte, type}
  | {:range, {min :: type, max :: type}}

schema()

@type schema() :: schema_def() | map_schema() | [{atom(), schema_def()}]

schema_def()

@type schema_def() ::
  :any
  | :atom
  | :atom!
  | :boolean
  | :list
  | :map
  | :pid
  | {:either, {schema_def(), schema_def()}}
  | {:oneof, [schema_def()]}
  | {:required, schema_def()}
  | {:meta, schema_def(), keyword()}
  | {:ref, atom()}
  | {:ref, {module(), atom()}}
  | {:multi, atom(), %{optional(term()) => schema_def()}}
  | {:enum, [term()]}
  | {:enum, [term()], keyword()}
  | {:list, schema_def()}
  | {:map, schema_def()}
  | {:map, key_type :: schema_def(), value_type :: schema_def()}
  | {:tuple, [schema_def()]}
  | {:literal, literal()}
  | time_def()
  | string_def()
  | int_def()
  | float_def()
  | default_def()
  | transform_def()
  | custom_def()
  | coerce_def()
  | encode_def()

string_def()

@type string_def() :: :string | {:string, string_option() | [string_option()]}

string_option()

@type string_option() ::
  {:regex, Regex.t()}
  | {:eq, String.t()}
  | {:min, integer()}
  | {:max, integer()}

time_def()

@type time_def() :: :time | :date | :datetime | :naive_datetime | :duration

transform_def()

@type transform_def() ::
  {schema_def(), {:transform, (term() -> term()) | (term(), term() -> term())}}
  | {schema_def(), {:transform, {module(), atom()}}}
  | {schema_def(), {:transform, {module(), atom(), [term()]}}}

validation()

@type validation() :: (term() -> validation_result())

validation_opt()

@type validation_opt() :: {:mode, :strict | :permissive} | {:spellcheck, boolean()}

validation_opts()

@type validation_opts() :: [validation_opt()]

validation_result()

@type validation_result() ::
  :ok | {:error, template :: String.t(), context :: map() | keyword()}

Functions

conforms?(schema, data, opts \\ [])

@spec conforms?(schema(), term(), validation_opts()) :: boolean()

Checks if the given data conforms to the specified schema.

Parameters

  • schema: The schema definition to validate against.
  • data: The data to be validated.

Options

  • :mode - Validation mode. Can be :strict (default) or :permissive.
    • :strict - Only fields defined in the schema are returned.
    • :permissive - All fields from the input data are preserved.
  • :spellcheck - Set to false to skip missing-required-field typo suggestions. Defaults to true.

Returns

  • true if the data conforms to the schema.
  • false if the data does not conform to the schema.

Examples

iex> schema = %{name: :string, age: :integer}
iex> data = %{name: "Alice", age: 30}
iex> Backplane.Schema.conforms?(schema, data)
true

iex> invalid_data = %{name: "Alice", age: "thirty"}
iex> Backplane.Schema.conforms?(schema, invalid_data)
false

decode(schema, data, opts \\ [])

@spec decode(schema(), term(), validation_opts()) :: {:ok, term()} | {:error, term()}

Decodes wire data against a schema.

Coercion is a schema directive, so decoding is exactly validate/3: {:coerce, source, target} fields convert their source representation into the target type, and values already matching the target pass through unchanged.

Accepts the same options as validate/3 and returns the same shape.

Examples

iex> schema = %{page: {:coerce, :string, :integer}}
iex> Backplane.Schema.decode(schema, %{"page" => "2"})
{:ok, %{page: 2}}

iex> schema = %{page: {:coerce, :string, :integer}}
iex> Backplane.Schema.decode(schema, %{page: 2})
{:ok, %{page: 2}}

defschema(name, schema, opts \\ [])

(macro)

Defines a schema with a given name and schema definition.

Examples

defmodule MySchemas do
  import Backplane.Schema

  defschema :user, %{
    name: :string,
    age: :integer,
    email: {:required, :string}
  }

  # With permissive mode
  defschema :flexible_user, %{
    name: :string,
    email: {:required, :string}
  }, mode: :permissive

  # With metadata (field-level and schema-level)
  defschema :documented_user, %{
    email: {:meta, {:required, :string}, doc: "Login email", example: "a@b.io"}
  }, title: "User", description: "Account holder"
end

# Schema-level metadata is accessible via __schema_meta__/1:
MySchemas.__schema_meta__(:documented_user)
# => [title: "User", description: "Account holder"]

user_data = %{name: "John", age: 30, email: "john@example.com"}
MySchemas.user(user_data)
# => {:ok, %{name: "John", age: 30, email: "john@example.com"}}

invalid_data = %{name: "John", age: 30}
MySchemas.user(invalid_data)
# => {:error, [email: "is required"]}

# Permissive mode preserves extra fields
flexible_data = %{name: "John", email: "john@example.com", role: "admin"}
MySchemas.flexible_user(flexible_data)
# => {:ok, %{name: "John", email: "john@example.com", role: "admin"}}

encode(schema, data, opts \\ [])

@spec encode(schema(), term(), validation_opts()) :: {:ok, term()} | {:error, term()}

Encodes data against a schema, producing its wire representation.

Validates data against the schema (target types), then applies the reverse of each codec directive:

  • {:coerce, source, target} - built-in :string sources render with to_string/1 (to_iso8601/1 for date/time structs); when the encode: opt is given, that function/MFA is applied instead. Custom function/MFA sources without an encode: opt pass the validated value through unchanged.
  • {type, {:encode, fun}} - applies fun (or MFA) to the validated value. This directive is inert under validate/3 and decode/3.
  • {:transform, fun} - skipped; transforms are decode-only.

Accepts the same options as validate/3 and returns the same shape.

Examples

iex> schema = %{page: {:coerce, :string, :integer}}
iex> Backplane.Schema.encode(schema, %{page: 2})
{:ok, %{page: "2"}}

iex> schema = %{upcased: {:string, {:encode, &String.downcase/1}}}
iex> Backplane.Schema.encode(schema, %{upcased: "HELLO"})
{:ok, %{upcased: "hello"}}

except(schema, keys)

@spec except(map_schema(), [atom() | String.t()]) ::
  {:ok, map_schema()} | {:error, [Backplane.Schema.Error.t()]}

Drops the given top-level keys from a map schema.

Keys not present in the schema are ignored. The resulting schema is checked with validate_schema/1, returning its error tuple when invalid.

Examples

iex> Backplane.Schema.except(%{name: :string, age: :integer}, [:age])
{:ok, %{name: :string}}

iex> Backplane.Schema.except(%{name: :string}, [:missing])
{:ok, %{name: :string}}

from_json_schema(json_schema, opts \\ [])

@spec from_json_schema(map(), [opt]) :: {:ok, schema()} | {:error, term()}
when opt: {:keys, :strings | :atoms | :atoms!}

Decodes a JSON Schema (Draft 7) map into a Backplane.Schema schema.

Returns {:ok, schema} if the resulting Backplane.Schema schema is valid, otherwise {:error, errors}.

get_enumerable_value(enum, key)

is_enumerable(data)

(macro)

Checks if the given data is an enumerable, specifically a map or a list.

Parameters

  • data: The data to check.

Examples

iex> is_enumerable(%{})
true

iex> is_enumerable([])
true

iex> is_enumerable(123)
false

iex> is_enumerable("string")
false

is_numeric(n)

(macro)

Checks if the given data is a numeric value, specifically a integer or a float.

Parameters

  • data: The data to check.

Examples

iex> is_numeric(123)
true

iex> is_numeric(0xFF)
true

iex> is_numeric(12.12)
true

iex> is_numeric("string")
false

iex> is_numeric(%{})
false

is_numeric_type(t)

(macro)

Checks if the given type as an atom is a numeric (integer or float).

Parameters

  • data: The data to check.

Examples

iex> is_numeric(:integer)
true

iex> is_numeric(:float)
true

iex> is_numeric(:list)
false

iex> is_numeric({:enum, _})
false

is_of_numeric_type(val, t)

(macro)

is_type_with_multiple_options(t)

(macro)

merge(schema1, schema2)

@spec merge(map_schema(), map_schema()) ::
  {:ok, map_schema()} | {:error, [Backplane.Schema.Error.t()]}

Deep merges two map schemas into a new one.

When both schemas define the same key as a plain map (a nested schema), the nested schemas are merged recursively. Otherwise the value from the right side wins: type tuples and directives are never merged structurally.

The resulting schema is checked with validate_schema/1, returning its error tuple when the composed schema is invalid.

Examples

iex> Backplane.Schema.merge(%{name: :string}, %{age: :integer})
{:ok, %{name: :string, age: :integer}}

iex> Backplane.Schema.merge(%{name: :string}, %{name: :atom})
{:ok, %{name: :atom}}

iex> Backplane.Schema.merge(%{user: %{name: :string}}, %{user: %{age: :integer}})
{:ok, %{user: %{name: :string, age: :integer}}}

put_in_enum(enum, key, val)

Helper function to put a value into an enum, handling not only maps and keyword lists but also structs.

Examples

iex> Backplane.Schema.put_in_enum(%{}, :hello, "world")
iex> Backplane.Schema.put_in_enum(%{}, "hello", "world")
iex> Backplane.Schema.put_in_enum(%User{}, :hello, "world")
iex> Backplane.Schema.put_in_enum([], :hello, "world")

select(schema, keys)

@spec select(map_schema(), [atom() | String.t()]) ::
  {:ok, map_schema()} | {:error, [Backplane.Schema.Error.t()]}

Keeps only the given top-level keys of a map schema.

Keys not present in the schema are ignored. The resulting schema is checked with validate_schema/1, returning its error tuple when invalid.

Examples

iex> Backplane.Schema.select(%{name: :string, age: :integer}, [:name])
{:ok, %{name: :string}}

iex> Backplane.Schema.select(%{name: :string}, [:name, :missing])
{:ok, %{name: :string}}

to_json_schema(schema, opts \\ [])

@spec to_json_schema(schema(), Backplane.Schema.JSONSchema.Encoder.opts()) :: map()

Converts a Backplane.Schema schema into a JSON Schema (Draft 7) map.

Reads {:meta, type, opts} annotations and emits title, description, examples, deprecated. Dynamic types degrade per :on_unsupported (:omit | :true_schema | :raise, default :omit).

Pass :exclude_meta_keys to drop annotation keywords from the output — commonly [:default] when the consumer-facing schema should not surface validation defaults.

Examples

iex> Backplane.Schema.to_json_schema(%{name: {:required, :string}})
%{"type" => "object", "properties" => %{"name" => %{"type" => "string"}}, "required" => ["name"]}

iex> Backplane.Schema.to_json_schema({:integer, {:default, 0}}, exclude_meta_keys: [:default])
%{"type" => "integer"}

validate(schema, data, opts \\ [])

@spec validate(schema(), term(), validation_opts()) ::
  {:ok, term()} | {:error, term()}

Validates a given data map against a schema with options.

Returns {:ok, data} if the data is valid according to the schema, or {:error, errors} if there are validation errors.

Parameters

  • schema: The schema definition map.
  • data: The data map to be validated.
  • opts: Options for validation.

Options

  • :mode - Validation mode. Can be :strict (default) or :permissive.
    • :strict - Only fields defined in the schema are returned.
    • :permissive - All fields from the input data are preserved.
  • :spellcheck - Set to false to skip missing-required-field typo suggestions. Defaults to true.

A schema returned by validate_schema/1 can be reused directly with validate/3 and the integrations that accept schemas. This avoids defschema's runtime schema validation for each call.

Examples

schema = %{name: :string, age: :integer}
data = %{name: "John", age: 30, extra: "field"}

# Strict mode (default)
Backplane.Schema.validate(schema, data)
# => {:ok, %{name: "John", age: 30}}

# Permissive mode
Backplane.Schema.validate(schema, data, mode: :permissive)
# => {:ok, %{name: "John", age: 30, extra: "field"}}

validate_schema(schema)

Validates a schema definition to ensure it adheres to the expected structure and types.

This function can handle both simple and complex schema definitions, including nested schemas, custom validation functions, and various type constraints.

Parameters

  • schema - The schema definition to be validated. It can be a map or a keyword list representing the schema.

Returns

  • {:ok, schema} - If the schema is valid, returns the original schema.
  • {:error, errors} - If the schema is invalid, returns an error tuple with detailed error information.

Examples

Validating a simple schema:

  schema = %{
    name: :string,
    age: :integer,
    email: {:required, :string}
  }
  assert {:ok, ^schema} = validate_schema(schema)

Validating a nested schema:

  schema = %{
    user: %{
      name: :string,
      profile: %{
        age: {:required, :integer},
        email: {:required, :string}
      }
    }
  }
  assert {:ok, ^schema} = validate_schema(schema)

Handling invalid schema definition:

  schema = %{
    name: :str,
    age: :integer,
    email: {:required, :string}
  }
  assert {:error, _errors} = validate_schema(schema)

walk(schema, fun)

Depth-first rewrite of a schema tree.

The callback is invoked on every subtree (pre-order). It must return either {:cont, new_node} to replace the node and continue, or :drop to remove it (only valid for values inside a map or keyword schema).

Building block for transforms like "make every field optional" or "strip internal-only fields from a public DTO". See Backplane.Schema.Walker for details.

Examples

iex> schema = %{name: {:required, :string}, age: {:required, :integer}}
iex> Backplane.Schema.walk(schema, fn
...>   {:required, t} -> {:cont, t}
...>   other -> {:cont, other}
...> end)
%{name: :string, age: :integer}