Packages

Spectral provides type-driven JSON encoding/decoding, JSON Schema generation, and OpenAPI 3.1 specification generation. It uses Elixir's type system to automatically handle serialization based on struct type definitions.

Current section

Files

Jump to
spectral lib spectral.ex
Raw

lib/spectral.ex

defmodule Spectral do
@moduledoc """
Elixir wrapper for the Erlang `spectra` library.
Provides idiomatic Elixir interfaces for encoding, decoding, and schema generation
based on type specifications.
## API
All functions are designed to work well with Elixir's pipe and with operators:
%Person{name: "Alice", age: 30}
|> Spectral.encode!(Person, :t)
|> send_response()
with {:ok, json} <- Spectral.encode(%Person{name: "Alice"}, Person, :t) do
send_response(json)
end
"""
@doc """
Encodes data to the specified format.
## Parameters
- `data` - The data to encode
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Format to encode to (default: `:json`)
## Returns
- `{:ok, iodata()}` - Encoded data on success
- `{:error, [%Spectral.Error{}]}` - List of errors on failure
## Examples
iex> person = %Person{name: "Alice", age: 30, address: %Person.Address{street: "Ystader Straße", city: "Berlin"}}
...> with {:ok, json} <- Spectral.encode(person, Person, :t) do
...> IO.iodata_to_binary(json)
...> end
~s({"address":{"city":"Berlin","street":"Ystader Straße"},"age":30,"name":"Alice"})
iex> {:ok, json} = %Person{name: "Alice"} |> Spectral.encode(Person, :t)
iex> IO.iodata_to_binary(json)
~s({"name":"Alice"})
"""
@spec encode(term(), module(), atom(), atom()) ::
{:ok, iodata()} | {:error, [Spectral.Error.t()]}
def encode(data, module, type_ref, format \\ :json) do
:spectra.encode(format, module, type_ref, data)
|> convert_result()
rescue
error in ErlangError ->
handle_erlang_error(error, :encode, module, type_ref)
end
@doc """
Decodes data from the specified format.
## Parameters
- `data` - The data to decode (binary for JSON, string for string format)
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Format to decode from (default: `:json`)
## Returns
- `{:ok, term()}` - Decoded data on success
- `{:error, [%Spectral.Error{}]}` - List of errors on failure
## Examples
iex> ~s({"name":"Alice","age":30,"address":{"street":"Ystader Straße", "city": "Berlin"}})
...> |> Spectral.decode(Person, :t)
{:ok, %Person{age: 30, name: "Alice", address: %Person.Address{street: "Ystader Straße", city: "Berlin"}}}
iex> ~s({"name":"Alice"})
...> |> Spectral.decode(Person, :t)
{:ok, %Person{age: nil, name: "Alice", address: nil}}
iex> ~s({"name":"Alice","age":30,"extra_field":"ignored"})
...> |> Spectral.decode(Person, :t)
{:ok, %Person{age: 30, name: "Alice", address: nil}}
"""
@spec decode(term(), module(), atom(), atom()) ::
{:ok, term()} | {:error, [Spectral.Error.t()]}
def decode(data, module, type_ref, format \\ :json) do
:spectra.decode(format, module, type_ref, data)
|> convert_result()
rescue
error in ErlangError ->
handle_erlang_error(error, :decode, module, type_ref)
end
@doc """
Generates a schema for the specified type.
## Parameters
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Schema format (default: `:json_schema`)
## Returns
- `{:ok, iodata()}` - Generated schema on success
- `{:error, [%Spectral.Error{}]}` - List of errors on failure
## Examples
iex> {:ok, schemadata} = Spectral.schema(Person, :t)
iex> is_binary(IO.iodata_to_binary(schemadata))
true
"""
@spec schema(module(), atom(), atom()) ::
{:ok, iodata()} | {:error, [Spectral.Error.t()]}
def schema(module, type_ref, format \\ :json_schema) do
:spectra.schema(format, module, type_ref)
|> convert_result()
rescue
error in ErlangError ->
handle_erlang_error(error, :schema, module, type_ref)
end
@doc """
Encodes data to the specified format, raising on error.
Like `encode/4` but raises `Spectral.Error` instead of returning an error tuple.
## Parameters
- `data` - The data to encode
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Format to encode to (default: `:json`)
## Returns
- `iodata()` - Encoded data on success
## Raises
- `Spectral.Error` - If encoding fails
## Examples
iex> %Person{name: "Alice", age: 30}
...> |> Spectral.encode!(Person, :t)
...> |> IO.iodata_to_binary()
~s({"age":30,"name":"Alice"})
"""
@spec encode!(term(), module(), atom(), atom()) :: iodata()
def encode!(data, module, type_ref, format \\ :json) do
case encode(data, module, type_ref, format) do
{:ok, result} ->
result
{:error, [error | _]} ->
raise Spectral.Error.exception(error)
end
end
@doc """
Decodes data from the specified format, raising on error.
Like `decode/4` but raises `Spectral.Error` instead of returning an error tuple.
## Parameters
- `data` - The data to decode (binary for JSON, string for string format)
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Format to decode from (default: `:json`)
## Returns
- `term()` - Decoded data on success
## Raises
- `Spectral.Error` - If decoding fails
## Examples
iex> ~s({"name":"Alice","age":30})
...> |> Spectral.decode!(Person, :t)
%Person{age: 30, name: "Alice", address: nil}
"""
@spec decode!(term(), module(), atom(), atom()) :: term()
def decode!(data, module, type_ref, format \\ :json) do
case decode(data, module, type_ref, format) do
{:ok, result} ->
result
{:error, [error | _]} ->
raise Spectral.Error.exception(error)
end
end
@doc """
Generates a schema for the specified type, raising on error.
Like `schema/3` but raises `Spectral.Error` instead of returning an error tuple.
## Parameters
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Schema format (default: `:json_schema`)
## Returns
- `iodata()` - Generated schema on success
## Raises
- `Spectral.Error` - If schema generation fails
## Examples
iex> schemadata = Spectral.schema!(Person.Address, :t)
iex> IO.iodata_to_binary(schemadata)
~s({"type":"object","required":["street","city"],"additionalProperties":false,"properties":{"city":{"type":"string"},"street":{"type":"string"}},"$schema":"https://json-schema.org/draft/2020-12/schema"})
"""
@spec schema!(module(), atom(), atom()) :: iodata()
def schema!(module, type_ref, format \\ :json_schema) do
case schema(module, type_ref, format) do
{:ok, result} ->
result
{:error, [error | _]} ->
# Call exception/1 to populate the message field
raise Spectral.Error.exception(error)
end
end
# Private helper to convert Erlang results to Elixir
defp convert_result({:ok, result}), do: {:ok, result}
defp convert_result({:error, erlang_errors}) when is_list(erlang_errors) do
{:error, Spectral.Error.from_erlang_list(erlang_errors)}
end
# Handles Erlang errors from the spectra library and converts configuration
# errors to idiomatic Elixir ArgumentErrors
defp handle_erlang_error(%ErlangError{original: original} = error, operation, module, type_ref) do
case original do
{:module_types_not_found, ^module, _reason} ->
raise ArgumentError,
"module #{inspect(module)} not found, not loaded, or not compiled with debug_info (#{operation})"
{:type_or_record_not_found, ^type_ref} ->
raise ArgumentError,
"type #{inspect(type_ref)} not found in module #{inspect(module)} (#{operation})"
{:type_not_supported, type_info} ->
raise ArgumentError,
"type not supported: #{inspect(type_info)} (#{operation})"
_other ->
# Re-raise the original ErlangError if it's not a known configuration error
raise error
end
end
end