Packages

Advanced schema definition and validation library for Elixir

Current section

Files

Jump to
exdantic lib exdantic wrapper.ex
Raw

lib/exdantic/wrapper.ex

defmodule Exdantic.Wrapper do
@moduledoc """
Temporary validation schemas for type coercion patterns.
This module supports the DSPy pattern of creating temporary, single-field
validation schemas for complex type coercion, equivalent to Pydantic's:
`create_model("Wrapper", value=(target_type, ...))`
Wrapper schemas are useful when you need to:
- Validate a single value against a complex type specification
- Apply field-level constraints and coercion
- Extract and unwrap validated values
- Perform temporary schema-based validation without defining a full schema
"""
alias Exdantic.{Runtime, TypeAdapter}
alias Exdantic.Runtime.DynamicSchema
@type wrapper_schema :: DynamicSchema.t()
@type wrapper_options :: [
required: boolean(),
coerce: boolean(),
constraints: [term()],
description: String.t(),
example: term(),
default: term()
]
@doc """
Creates a temporary wrapper schema for validating a single value.
## Parameters
* `field_name` - The name for the wrapper field (atom)
* `type_spec` - The type specification for the field
* `opts` - Wrapper configuration options
## Options
* `:required` - Whether the field is required (default: true)
* `:coerce` - Enable type coercion (default: false)
* `:constraints` - Additional field constraints (default: [])
* `:description` - Field description for documentation
* `:example` - Example value for the field
* `:default` - Default value if field is missing
## Returns
* Wrapper schema that can be used for validation
## Examples
iex> wrapper = Exdantic.Wrapper.create_wrapper(:result, :integer, coerce: true, constraints: [gt: 0])
%Exdantic.Runtime.DynamicSchema{...}
iex> wrapper = Exdantic.Wrapper.create_wrapper(:email, :string,
...> constraints: [format: ~r/@/], description: "Email address")
%Exdantic.Runtime.DynamicSchema{...}
"""
@spec create_wrapper(atom(), TypeAdapter.type_spec(), wrapper_options()) :: wrapper_schema()
def create_wrapper(field_name, type_spec, opts \\ [])
def create_wrapper(nil, _type_spec, _opts) do
raise ArgumentError, "field_name cannot be nil"
end
def create_wrapper(field_name, type_spec, opts) when is_atom(field_name) do
# Note: Type specification validation is handled during schema creation and validation
do_create_wrapper(field_name, type_spec, opts)
end
@spec do_create_wrapper(atom(), TypeAdapter.type_spec(), wrapper_options()) :: wrapper_schema()
defp do_create_wrapper(field_name, type_spec, opts) when is_atom(field_name) do
# Extract wrapper options
required = Keyword.get(opts, :required, true)
description = Keyword.get(opts, :description)
example = Keyword.get(opts, :example)
default = Keyword.get(opts, :default)
constraints = Keyword.get(opts, :constraints, [])
coerce = Keyword.get(opts, :coerce, false)
# Create field definition with constraints
field_definition = {
field_name,
type_spec,
[
required: required,
description: description,
example: example,
default: default
] ++ constraints
}
# Generate unique wrapper name
wrapper_name = "Wrapper_#{field_name}_#{System.unique_integer([:positive])}"
# Create runtime schema with single field
schema =
Runtime.create_schema(
[field_definition],
name: wrapper_name,
title: "Wrapper for #{field_name}",
description: "Temporary validation schema for #{field_name}"
)
# Store coercion setting in metadata for later use
%{schema | metadata: Map.put(schema.metadata, :coerce, coerce)}
end
@doc """
Validates data using a wrapper schema and extracts the field value.
## Parameters
* `wrapper_schema` - The wrapper schema created by create_wrapper/3
* `data` - The data to validate (can be the raw value or a map)
* `field_name` - The field name to extract from the validated result
## Returns
* `{:ok, extracted_value}` on successful validation and extraction
* `{:error, errors}` on validation failure
## Examples
iex> wrapper = Exdantic.Wrapper.create_wrapper(:count, :integer, coerce: true)
iex> Exdantic.Wrapper.validate_and_extract(wrapper, %{count: "42"}, :count)
{:ok, 42}
iex> Exdantic.Wrapper.validate_and_extract(wrapper, "42", :count) # Auto-wrap
{:ok, 42}
iex> Exdantic.Wrapper.validate_and_extract(wrapper, %{count: "abc"}, :count)
{:error, [%Exdantic.Error{...}]}
"""
@spec validate_and_extract(wrapper_schema(), term(), atom()) ::
{:ok, term()} | {:error, [Exdantic.Error.t()]}
def validate_and_extract(%DynamicSchema{} = wrapper_schema, data, field_name) do
# Get field metadata to check the expected type
field_meta = Map.get(wrapper_schema.fields, field_name)
# Normalize data to map format if needed
normalized_data = normalize_wrapper_data(data, field_name, field_meta)
# Check if coercion is enabled for this wrapper
coerce_enabled = Map.get(wrapper_schema.metadata, :coerce, false)
if coerce_enabled do
# Use TypeAdapter for coercion-enabled validation
validate_with_coercion(wrapper_schema, normalized_data, field_name)
else
# Use normal Runtime validation
case Runtime.validate(normalized_data, wrapper_schema) do
{:ok, validated_map} ->
# Extract the field value
case Map.get(validated_map, field_name) do
nil ->
{:error,
[Exdantic.Error.new([field_name], :missing, "field not found in validated result")]}
value ->
{:ok, value}
end
{:error, errors} ->
{:error, errors}
end
end
end
@doc """
Validates data using a wrapper schema and extracts the value in one step.
This is a convenience function that combines create_wrapper/3 and validate_and_extract/3.
## Parameters
* `field_name` - The name for the wrapper field
* `type_spec` - The type specification for the field
* `input` - The data to validate
* `opts` - Wrapper configuration options (same as create_wrapper/3)
## Returns
* `{:ok, validated_value}` on successful validation
* `{:error, errors}` on validation failure
## Examples
iex> Exdantic.Wrapper.wrap_and_validate(:score, :integer, "85", coerce: true, constraints: [gteq: 0, lteq: 100])
{:ok, 85}
iex> Exdantic.Wrapper.wrap_and_validate(:email, :string, "invalid", constraints: [format: ~r/@/])
{:error, [%Exdantic.Error{...}]}
iex> Exdantic.Wrapper.wrap_and_validate(:items, {:array, :string}, ["a", "b", "c"])
{:ok, ["a", "b", "c"]}
"""
@spec wrap_and_validate(atom(), TypeAdapter.type_spec(), term(), wrapper_options()) ::
{:ok, term()} | {:error, [Exdantic.Error.t()]}
def wrap_and_validate(field_name, type_spec, input, opts \\ []) when is_atom(field_name) do
wrapper_schema = create_wrapper(field_name, type_spec, opts)
validate_and_extract(wrapper_schema, input, field_name)
end
@doc """
Creates multiple wrapper schemas for batch validation.
## Parameters
* `field_specs` - List of {field_name, type_spec, opts} tuples
* `global_opts` - Options applied to all wrappers
## Returns
* Map of field_name => wrapper_schema
## Examples
iex> specs = [
...> {:name, :string, [constraints: [min_length: 1]]},
...> {:age, :integer, [constraints: [gt: 0]]},
...> {:email, :string, [constraints: [format: ~r/@/]]}
...> ]
iex> wrappers = Exdantic.Wrapper.create_multiple_wrappers(specs)
%{name: %DynamicSchema{...}, age: %DynamicSchema{...}, email: %DynamicSchema{...}}
"""
@spec create_multiple_wrappers(
[{atom(), TypeAdapter.type_spec(), wrapper_options()}],
wrapper_options()
) ::
%{atom() => wrapper_schema()}
def create_multiple_wrappers(field_specs, global_opts \\ []) do
mapper_fn = fn {field_name, type_spec, opts} ->
merged_opts = Keyword.merge(global_opts, opts)
wrapper = create_wrapper(field_name, type_spec, merged_opts)
{field_name, wrapper}
end
field_specs
|> Enum.map(mapper_fn)
|> Map.new()
end
@doc """
Validates multiple values using their respective wrapper schemas.
## Parameters
* `wrappers` - Map of field_name => wrapper_schema
* `data` - Map of field_name => value to validate
## Returns
* `{:ok, validated_values}` if all validations succeed
* `{:error, errors_by_field}` if any validation fails
## Examples
iex> wrappers = %{
...> name: Exdantic.Wrapper.create_wrapper(:name, :string),
...> age: Exdantic.Wrapper.create_wrapper(:age, :integer)
...> }
iex> data = %{name: "John", age: 30}
iex> Exdantic.Wrapper.validate_multiple(wrappers, data)
{:ok, %{name: "John", age: 30}}
"""
@spec validate_multiple(%{atom() => wrapper_schema()}, %{atom() => term()}) ::
{:ok, %{atom() => term()}} | {:error, %{atom() => [Exdantic.Error.t()]}}
def validate_multiple(wrappers, data) when is_map(wrappers) and is_map(data) do
results =
wrappers
|> Enum.map(fn {field_name, wrapper_schema} ->
case Map.get(data, field_name) do
nil ->
{field_name,
{:error, [Exdantic.Error.new([field_name], :missing, "field not provided")]}}
value ->
case validate_and_extract(wrapper_schema, value, field_name) do
{:ok, validated} -> {field_name, {:ok, validated}}
{:error, errors} -> {field_name, {:error, errors}}
end
end
end)
case Enum.split_with(results, fn {_, result} -> match?({:ok, _}, result) end) do
{oks, []} ->
validated_map =
oks
|> Enum.map(fn {field_name, {:ok, value}} -> {field_name, value} end)
|> Map.new()
{:ok, validated_map}
{_, errors} ->
error_map =
errors
|> Enum.map(fn {field_name, {:error, errs}} -> {field_name, errs} end)
|> Map.new()
{:error, error_map}
end
end
@doc """
Creates a reusable wrapper factory for a specific type and constraints.
## Parameters
* `type_spec` - The type specification for the wrapper
* `base_opts` - Base options applied to all wrappers created by this factory
## Returns
* Function that creates wrappers with the specified type and base options
## Examples
iex> email_wrapper_factory = Exdantic.Wrapper.create_wrapper_factory(
...> :string,
...> constraints: [format: ~r/@/],
...> description: "Email address"
...> )
iex> user_email_wrapper = email_wrapper_factory.(:user_email)
iex> admin_email_wrapper = email_wrapper_factory.(:admin_email, required: false)
"""
@spec create_wrapper_factory(TypeAdapter.type_spec(), wrapper_options()) ::
(atom() -> wrapper_schema())
def create_wrapper_factory(type_spec, base_opts \\ []) do
wrapper_fn = fn field_name ->
create_wrapper(field_name, type_spec, base_opts)
end
wrapper_fn
end
@doc """
Converts a wrapper schema back to its JSON Schema representation.
## Parameters
* `wrapper_schema` - The wrapper schema to convert
* `opts` - JSON Schema generation options
## Returns
* JSON Schema map representation of the wrapper
## Examples
iex> wrapper = Exdantic.Wrapper.create_wrapper(:count, :integer, constraints: [gt: 0])
iex> Exdantic.Wrapper.to_json_schema(wrapper)
%{
"type" => "object",
"properties" => %{
"count" => %{"type" => "integer", "exclusiveMinimum" => 0}
},
"required" => ["count"]
}
"""
@spec to_json_schema(wrapper_schema(), keyword()) :: map()
def to_json_schema(%DynamicSchema{} = wrapper_schema, opts \\ []) do
Runtime.to_json_schema(wrapper_schema, opts)
end
@doc """
Unwraps a validated result, extracting just the field value.
Utility function for extracting values from wrapper validation results.
## Parameters
* `validated_result` - Result from wrapper validation (map)
* `field_name` - The field name to extract
## Returns
* The unwrapped field value
## Examples
iex> validated = %{score: 85}
iex> Exdantic.Wrapper.unwrap_result(validated, :score)
85
"""
@spec unwrap_result(map(), atom()) :: term()
def unwrap_result(validated_result, field_name) when is_map(validated_result) do
Map.get(validated_result, field_name)
end
@doc """
Checks if a schema is a wrapper schema created by this module.
## Parameters
* `schema` - The schema to check
## Returns
* `true` if it's a wrapper schema, `false` otherwise
## Examples
iex> wrapper = Exdantic.Wrapper.create_wrapper(:test, :string)
iex> Exdantic.Wrapper.wrapper_schema?(wrapper)
true
iex> regular_schema = Exdantic.Runtime.create_schema([{:name, :string}])
iex> Exdantic.Wrapper.wrapper_schema?(regular_schema)
false
"""
@spec wrapper_schema?(term()) :: boolean()
def wrapper_schema?(%DynamicSchema{name: name}) when is_binary(name) do
String.starts_with?(name, "Wrapper_")
end
def wrapper_schema?(_), do: false
@doc """
Gets metadata about a wrapper schema.
## Parameters
* `wrapper_schema` - The wrapper schema to inspect
## Returns
* Map with wrapper metadata
## Examples
iex> wrapper = Exdantic.Wrapper.create_wrapper(:email, :string, description: "User email")
iex> Exdantic.Wrapper.wrapper_info(wrapper)
%{
is_wrapper: true,
field_name: :email,
field_count: 1,
wrapper_type: :single_field,
created_at: ~U[...]
}
"""
@spec wrapper_info(wrapper_schema()) :: map()
def wrapper_info(%DynamicSchema{} = wrapper_schema) do
field_names = Runtime.DynamicSchema.field_names(wrapper_schema)
%{
is_wrapper: wrapper_schema?(wrapper_schema),
field_name: List.first(field_names),
field_count: length(field_names),
wrapper_type: if(length(field_names) == 1, do: :single_field, else: :multi_field),
created_at: Map.get(wrapper_schema.metadata, :created_at),
schema_name: wrapper_schema.name
}
end
@doc """
Creates a wrapper schema that can handle multiple input formats.
This wrapper can accept:
- The raw value directly
- A map with the field name as key
- A map with string keys
## Parameters
* `field_name` - The field name for the wrapper
* `type_spec` - The type specification
* `opts` - Wrapper options
## Examples
iex> wrapper = Exdantic.Wrapper.create_flexible_wrapper(:age, :integer, coerce: true)
iex> Exdantic.Wrapper.validate_flexible(wrapper, 25, :age) # Raw value
{:ok, 25}
iex> Exdantic.Wrapper.validate_flexible(wrapper, %{age: 25}, :age) # Map with atom key
{:ok, 25}
iex> Exdantic.Wrapper.validate_flexible(wrapper, %{"age" => 25}, :age) # Map with string key
{:ok, 25}
"""
@spec create_flexible_wrapper(atom(), TypeAdapter.type_spec(), wrapper_options()) ::
wrapper_schema()
def create_flexible_wrapper(field_name, type_spec, opts \\ []) when is_atom(field_name) do
create_wrapper(field_name, type_spec, opts)
end
@doc """
Validates data against a flexible wrapper that can handle multiple input formats.
## Parameters
* `wrapper_schema` - The wrapper schema
* `data` - The input data (raw value, or map with atom/string keys)
* `field_name` - The field name to extract
## Returns
* `{:ok, validated_value}` on success
* `{:error, errors}` on failure
"""
@spec validate_flexible(wrapper_schema(), term(), atom()) ::
{:ok, term()} | {:error, [Exdantic.Error.t()]}
def validate_flexible(%DynamicSchema{} = wrapper_schema, data, field_name) do
normalized_data = normalize_flexible_data(data, field_name)
validate_and_extract(wrapper_schema, normalized_data, field_name)
end
# Private helper functions
@spec validate_with_coercion(wrapper_schema(), map(), atom()) ::
{:ok, term()} | {:error, [Exdantic.Error.t()]}
defp validate_with_coercion(%DynamicSchema{} = wrapper_schema, normalized_data, field_name) do
# Get the field metadata
field_meta = Map.get(wrapper_schema.fields, field_name)
if field_meta do
# Extract the value to validate
value = Map.get(normalized_data, field_name)
# Use TypeAdapter for validation with coercion
case TypeAdapter.validate(field_meta.type, value, coerce: true, path: [field_name]) do
{:ok, validated_value} ->
{:ok, validated_value}
{:error, errors} ->
{:error, errors}
end
else
{:error, [Exdantic.Error.new([field_name], :missing, "field not found in wrapper schema")]}
end
end
@spec normalize_wrapper_data(term(), atom(), Exdantic.FieldMeta.t() | nil) :: map()
defp normalize_wrapper_data(data, field_name, field_meta) when is_map(data) do
# If data is already a map, check if the field exists with atom or string key
case {Map.get(data, field_name), Map.get(data, Atom.to_string(field_name))} do
{nil, nil} ->
# Field not found in map - decide whether to treat map as field value or report missing
if should_treat_map_as_value?(data, field_meta) do
# Field type is compatible with maps, treat entire map as the field value
%{field_name => data}
else
# Field type is not map-compatible, leave empty so validation reports missing field
%{}
end
# Atom key found
{value, nil} ->
%{field_name => value}
# String key found
{nil, value} ->
%{field_name => value}
# Prefer atom key
{atom_value, _} ->
%{field_name => atom_value}
end
end
defp normalize_wrapper_data(data, field_name, _field_meta) do
# For non-map data, wrap it in a map with the field name
%{field_name => data}
end
@spec normalize_flexible_data(term(), atom()) :: map()
defp normalize_flexible_data(data, field_name) when is_map(data) do
# Try different key formats
cond do
Map.has_key?(data, field_name) ->
data
Map.has_key?(data, Atom.to_string(field_name)) ->
# Convert string key to atom key
value = Map.get(data, Atom.to_string(field_name))
%{field_name => value}
true ->
# If no matching key found, treat the entire map as the field value
%{field_name => data}
end
end
defp normalize_flexible_data(data, field_name) do
# For non-map data, wrap it
%{field_name => data}
end
@spec should_treat_map_as_value?(map(), Exdantic.FieldMeta.t() | nil) :: boolean()
defp should_treat_map_as_value?(_data, nil), do: false
defp should_treat_map_as_value?(_data, field_meta) do
map_compatible_type?(field_meta.type)
end
@spec map_compatible_type?(term()) :: boolean()
defp map_compatible_type?(type) do
case type do
# Map and any types
type when type in [:map, :any] -> true
{:type, type, _} when type in [:map, :any] -> true
# Map with additional type info
{:map, _} -> true
{:map, _, _} -> true
# Reference types (schemas accept map data)
{:ref, _} -> true
{:type, :ref, _} -> true
# All other types
_ -> false
end
end
end