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
require Record
Record.defrecord(
:sp_function_spec,
Record.extract(:sp_function_spec, from_lib: "spectra/include/spectra_internal.hrl")
)
@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
"""
@typedoc "Spectra type information for a module. Alias for `:spectra.type_info()`."
@type type_info :: :spectra.type_info()
@typedoc "A reference to a named type `{:type, name, arity}` or record `{:record, name}`."
@type sp_type_reference :: {:type, atom(), non_neg_integer()} | {:record, atom()}
@typedoc "A spectra type structure or type reference. `sp_type()` is an opaque Erlang record."
@type sp_type_or_ref :: :spectra.sp_type_or_ref()
@typedoc """
Options for `encode/5` and `encode!/5`.
- `:pre_encoded` - Skip the final JSON serialization step and return the intermediate
JSON term (a map/list) instead of iodata. Equivalent to `{:pre_encoded, true}`.
- `{:pre_encoded, boolean()}` - Explicit boolean form; `false` gives the default behaviour.
"""
@type encode_option :: :pre_encoded | {:pre_encoded, boolean()}
@typedoc """
Options for `decode/5` and `decode!/5`.
- `:pre_decoded` - Accept an already-decoded JSON term as input, skipping the JSON
parsing step. Equivalent to `{:pre_decoded, true}`.
- `{:pre_decoded, boolean()}` - Explicit boolean form; `false` gives the default behaviour.
"""
@type decode_option :: :pre_decoded | {:pre_decoded, boolean()}
@typedoc """
Options for `schema/4`.
- `:pre_encoded` - Skip the final JSON serialization step and return a map instead of
iodata. Equivalent to `{:pre_encoded, true}`.
- `{:pre_encoded, boolean()}` - Explicit boolean form; `false` gives the default behaviour.
"""
@type schema_option :: :pre_encoded | {:pre_encoded, boolean()}
@doc """
Adds documentation metadata for a type.
Use this macro immediately before a `@type` definition to document it.
The line number is automatically captured to pair the documentation
with the correct type.
## Example
defmodule Person do
use Spectral
spectral title: "Person", description: "A person record"
@type t :: %Person{name: String.t()}
end
## Fields — before a `@type`
- `title` - A short title for the type
- `description` - A detailed description
- `deprecated` - Whether the type is deprecated (boolean)
- `examples` - Example values (list)
- `examples_function` - `{module, function_name, args}` tuple; called at schema
generation time to produce examples. The function must be exported.
- `type_parameters` - Static configuration forwarded as the `params` argument
to `Spectral.Codec` callbacks for this type (any term)
## Fields — before a `@spec`
- `summary` - Short one-line summary of the function / endpoint
- `description` - A detailed description
- `deprecated` - Whether the function is deprecated (boolean)
"""
defmacro spectral(metadata) when is_list(metadata) do
line = __CALLER__.line
quote do
@spectral {unquote(line), Map.new(unquote(metadata))}
end
end
defmacro spectral({:%{}, _meta, fields}) when is_list(fields) do
line = __CALLER__.line
quote do
@spectral {unquote(line), Map.new(unquote(fields))}
end
end
defmacro spectral(metadata) do
raise ArgumentError, """
spectral macro requires a keyword list or map, got: #{inspect(metadata)}
Valid usage:
spectral title: "My Type", description: "A description"
spectral %{title: "My Type", description: "A description"}
"""
end
@doc """
Sets up the Spectral macros and injects `__spectra_type_info__/0` function.
When you `use Spectral`, the following happens:
- The `spectral/1` macro is imported for documenting types and functions
- A `__spectra_type_info__/0` function is injected that returns type information
- The `@spectral` attribute is registered (used internally by the `spectral/1` macro)
## Annotating Types
Place `spectral/1` immediately before a `@type` definition to attach documentation
that will appear in generated JSON schemas and OpenAPI component schemas:
defmodule Person do
use Spectral
defstruct [:name, :age]
spectral title: "Person", description: "A person record"
@type t :: %Person{name: String.t(), age: non_neg_integer()}
end
Types without a `spectral` call will not have title/description in their JSON schemas.
### Type Documentation Fields
- `title` - A short title for the type (string)
- `description` - A detailed description (string)
- `deprecated` - Whether the type is deprecated (boolean)
- `examples` - Example values (list)
- `examples_function` - `{module, function_name, args}` tuple; called at schema
generation time to produce examples. The function must be exported.
- `type_parameters` - Static configuration passed as the `params` argument to
`Spectral.Codec` callbacks for this type (any term)
## Annotating Functions (Endpoint Documentation)
Place `spectral/1` immediately before a `@spec` definition to attach endpoint
documentation. This metadata is used by `Spectral.OpenAPI.endpoint/5` to automatically
populate the OpenAPI operation fields:
defmodule MyController do
use Spectral
spectral summary: "Get user", description: "Returns a user by ID"
@spec show(map(), map()) :: map()
def show(_conn, _params), do: %{}
end
# Build the endpoint — docs are read automatically from the function's metadata
endpoint = Spectral.OpenAPI.endpoint(:get, "/users/{id}", MyController, :show, 2)
### Function Documentation Fields
- `summary` - Short summary of the endpoint operation (string)
- `description` - Longer description of the operation (string)
- `deprecated` - Whether the endpoint is deprecated (boolean)
## Multiple Annotations
A module can mix type and function annotations freely:
defmodule MyModule do
use Spectral
spectral title: "Public API", description: "The public interface"
@type public_api :: map()
spectral summary: "List items", description: "Returns all items"
@spec index(map(), map()) :: map()
def index(_conn, _params), do: %{}
end
## The `__spectra_type_info__/0` Function
The injected `__spectra_type_info__/0` function returns detailed type information for the module.
It extracts type definitions from the module's compiled BEAM file and enriches them with
documentation from `spectral` attributes.
### Return Value Structure
Returns a `type_info` record (Erlang record from spectra library):
{:type_info, types, records, functions}
#### Fields:
- **`types`** - Map of `{type_name, arity}` tuples to `sp_type` records. Each `sp_type` contains:
- Type structure information (e.g., `sp_map`, `sp_simple_type`, `sp_union`, etc.)
- A `meta` field containing optional documentation
- **`records`** - Map of record names (atoms) to `sp_rec` records containing record field information
- **`functions`** - Map of `{function_name, arity}` tuples to lists of `sp_function_spec` records.
When annotated with `spectral/1`, each spec's `meta.doc` field holds the endpoint documentation.
#### Type Documentation (meta field)
When you use the `spectral` macro to document a type, the documentation is stored in that
type's `meta` field as:
%{doc: %{title: "...", description: "...", examples: [...]}}
#### Function Documentation (sp_function_spec meta field)
When you use the `spectral` macro to document a function, the documentation is stored in
each matching `sp_function_spec`'s `meta` field as:
%{doc: %{summary: "...", description: "..."}}
Use `Spectral.TypeInfo.get_function_doc/3` to retrieve it.
### Example Usage
defmodule Person do
use Spectral
spectral title: "Person", description: "A person record"
@type t :: %Person{name: String.t()}
end
# Access type information
{:type_info, types, records, functions} = Person.__spectra_type_info__()
# Get the type definition for Person.t/0
person_type = types[{:t, 0}]
# Extract documentation from the type's meta field
meta = :spectra_type.get_meta(person_type)
# => %{doc: %{title: "Person", description: "A person record"}}
### Use Cases
This function is primarily used internally by Spectral's encoding, decoding, and schema
generation functions, but can be called directly for:
- Introspection and debugging
- Custom tooling that needs access to type information
- Documentation generation
- Type analysis and validation tools
"""
defmacro __using__(_opts) do
quote do
Module.register_attribute(__MODULE__, :spectral, accumulate: true)
import Spectral, only: [spectral: 1]
@before_compile Spectral
end
end
@doc false
# credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity
# credo:disable-for-this-file Credo.Check.Refactor.Nesting
defmacro __before_compile__(env) do
spectral_attrs = Module.get_attribute(env.module, :spectral) || []
# @type and @typep are stored under separate module attributes; combine them
# so that spectral/1 annotations can be paired with private types too.
type_attrs =
(Module.get_attribute(env.module, :type) || []) ++
(Module.get_attribute(env.module, :typep) || [])
spec_attrs = Module.get_attribute(env.module, :spec) || []
# The :spectral attribute accumulates in reverse order (LIFO), so reverse it to get source order.
spectral_in_order = Enum.reverse(spectral_attrs)
types_with_lines =
type_attrs
|> Enum.map(fn type_ast ->
case type_ast do
{kind, {:"::", meta, [{name, _, args_or_nil}, _type_expr]}, _env}
when kind in [:type, :typep] and is_atom(name) ->
arity = if is_list(args_or_nil), do: length(args_or_nil), else: 0
line = Keyword.get(meta, :line, 0)
{line, :type, {name, arity}}
other_ast ->
type_kind =
case other_ast do
{kind, _, _} when is_atom(kind) -> kind
_ -> :unknown
end
raise ArgumentError,
"Spectral.__before_compile__/1 encountered unsupported @type AST structure in #{inspect(env.module)}.\n" <>
"Type kind: #{inspect(type_kind)}\n" <>
"AST: #{inspect(other_ast, pretty: true)}\n\n" <>
"This might be a bug in Spectral or an unsupported type definition syntax.\n" <>
"Please report this at https://github.com/andreashasse/spectral/issues with the type definition that caused this error."
end
end)
specs_with_lines =
spec_attrs
|> Enum.flat_map(fn spec_ast ->
case spec_ast do
{:spec, {:"::", meta, [{name, _, args_or_nil}, _return_type]}, _env}
when is_atom(name) ->
arity = if is_list(args_or_nil), do: length(args_or_nil), else: 0
line = Keyword.get(meta, :line, 0)
[{line, :function, {name, arity}}]
{:spec, {:when, _, [{:"::", meta, [{name, _, args_or_nil}, _return_type]}, _]}, _env}
when is_atom(name) ->
arity = if is_list(args_or_nil), do: length(args_or_nil), else: 0
line = Keyword.get(meta, :line, 0)
[{line, :function, {name, arity}}]
_ ->
[]
end
end)
all_declarations =
Enum.sort_by(types_with_lines ++ specs_with_lines, fn {line, _, _} -> line end)
# Semantic pairing: match each @spectral with the @type or @spec that comes immediately after it
paired_docs =
Enum.map(spectral_in_order, fn spectral_doc ->
{spectral_line, doc} =
case spectral_doc do
{line, map} when is_integer(line) and is_map(map) ->
{line, map}
other ->
# This should never happen if spectral/1 macro validation is working
raise ArgumentError,
"Internal error: Invalid @spectral attribute format: #{inspect(other)} in #{inspect(env.module)}. " <>
"This is a bug in Spectral - please report it at https://github.com/andreashasse/spectral/issues"
end
{_decl_line, kind, ref} =
Enum.find(all_declarations, fn {decl_line, _, _} -> decl_line > spectral_line end) ||
raise ArgumentError,
"spectral call on line #{spectral_line} in #{inspect(env.module)} has no corresponding @type or @spec definition after it"
{kind, ref, doc}
end)
type_docs_to_add =
paired_docs
|> Enum.filter(fn {kind, _, _} -> kind == :type end)
|> Enum.map(fn {:type, {name, arity}, doc} -> {name, arity, doc} end)
function_docs_to_add =
paired_docs
|> Enum.filter(fn {kind, _, _} -> kind == :function end)
|> Enum.map(fn {:function, {name, arity}, doc} -> {name, arity, doc} end)
quote do
def __spectra_type_info__ do
beam_path =
case :code.which(__MODULE__) do
:cover_compiled ->
{_, _, path} = :code.get_object_code(__MODULE__)
path
path when is_list(path) ->
path
error ->
raise ArgumentError,
"Cannot find beam file for module #{inspect(__MODULE__)}: #{inspect(error)}"
end
type_info = :spectra_abstract_code.types_in_module_path(beam_path)
type_info_with_type_docs =
Enum.reduce(
unquote(Macro.escape(type_docs_to_add)),
type_info,
fn {name, arity, doc}, acc_type_info ->
case :spectra_type_info.find_type(acc_type_info, name, arity) do
{:ok, existing_type} ->
{type_params, doc_without_params} = Map.pop(doc, :type_parameters)
updated_type =
existing_type
|> :spectra_type.add_doc_to_type(doc_without_params)
|> then(fn t ->
if type_params != nil do
meta = :spectra_type.get_meta(t)
:spectra_type.set_meta(t, Map.put(meta, :parameters, type_params))
else
t
end
end)
:spectra_type_info.add_type(acc_type_info, name, arity, updated_type)
:error ->
acc_type_info
end
end
)
Enum.reduce(
unquote(Macro.escape(function_docs_to_add)),
type_info_with_type_docs,
fn {name, arity, doc}, acc_type_info ->
Spectral.__attach_function_doc__(acc_type_info, name, arity, doc)
end
)
end
end
end
@doc false
def __attach_function_doc__(type_info, name, arity, raw_doc) do
normalized_doc = :spectra_type.normalize_function_doc(raw_doc)
case :spectra_type_info.find_function(type_info, name, arity) do
{:ok, func_specs} ->
updated_specs =
Enum.map(func_specs, fn spec ->
meta = sp_function_spec(spec, :meta)
sp_function_spec(spec, meta: Map.put(meta, :doc, normalized_doc))
end)
:spectra_type_info.add_function(type_info, name, arity, updated_specs)
:error ->
type_info
end
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`)
- `opts` - Options list (default: `[]`). Supported options:
- `:pre_encoded` - Return the intermediate JSON term (map/list) instead of iodata.
## Returns
- `{:ok, iodata()}` - Encoded data on success (default)
- `{:ok, dynamic()}` - Encoded data as a JSON term when `:pre_encoded` option is set
- `{: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"})
iex> {:ok, term} = %Person{name: "Alice", age: 30} |> Spectral.encode(Person, :t, :json, [:pre_encoded])
iex> term["name"]
"Alice"
"""
@spec encode(dynamic(), module() | type_info(), atom() | sp_type_or_ref(), atom(), [
encode_option()
]) ::
{:ok, iodata() | dynamic()} | {:error, [Spectral.Error.t()]}
def encode(data, module, type_ref, format \\ :json, opts \\ []) do
:spectra.encode(format, module, type_ref, data, opts)
|> 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; or a JSON term when `:pre_decoded` option is set)
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Format to decode from (default: `:json`)
- `opts` - Options list (default: `[]`). Supported options:
- `:pre_decoded` - Accept an already-decoded JSON term as input, skipping JSON parsing.
## Returns
- `{:ok, dynamic()}` - 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}}
iex> Spectral.decode(%{"name" => "Alice", "age" => 30}, Person, :t, :json, [:pre_decoded])
{:ok, %Person{age: 30, name: "Alice", address: nil}}
"""
@spec decode(dynamic(), module() | type_info(), atom() | sp_type_or_ref(), atom(), [
decode_option()
]) ::
{:ok, dynamic()} | {:error, [Spectral.Error.t()]}
def decode(data, module, type_ref, format \\ :json, opts \\ []) do
:spectra.decode(format, module, type_ref, data, opts)
|> 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
- `iodata()` - Generated schema
## Examples
iex> schemadata = Spectral.schema(Person, :t)
iex> is_binary(IO.iodata_to_binary(schemadata))
true
"""
@spec schema(module() | type_info(), atom() | sp_type_or_ref(), atom()) :: iodata()
def schema(module, type_ref, format \\ :json_schema) do
:spectra.schema(format, module, type_ref)
rescue
error in ErlangError ->
handle_erlang_error(error, :schema, module, type_ref)
end
@doc """
Generates a schema for the specified type, with options.
Like `schema/3` but accepts an options list.
## Parameters
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Schema format (default: `:json_schema`)
- `opts` - Options list. Supported options:
- `:pre_encoded` - Return a map instead of iodata, skipping JSON encoding.
## Returns
- `iodata()` - Generated schema (default)
- `dynamic()` - Schema as a map when `:pre_encoded` option is set
## Examples
iex> schema = Spectral.schema(Person, :t, :json_schema, [:pre_encoded])
iex> is_map(schema)
true
"""
@spec schema(module() | type_info(), atom() | sp_type_or_ref(), atom(), [schema_option()]) ::
iodata() | dynamic()
def schema(module, type_ref, format, opts) when is_list(opts) do
:spectra.schema(format, module, type_ref, opts)
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/5` 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`)
- `opts` - Options list (default: `[]`). Supported options:
- `:pre_encoded` - Return the intermediate JSON term (map/list) instead of iodata.
## Returns
- `iodata()` - Encoded data on success (default)
- `dynamic()` - Encoded data as a JSON term when `:pre_encoded` option is set
## 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!(dynamic(), module() | type_info(), atom() | sp_type_or_ref(), atom(), [
encode_option()
]) :: iodata() | dynamic()
def encode!(data, module, type_ref, format \\ :json, opts \\ []) do
case encode(data, module, type_ref, format, opts) 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/5` but raises `Spectral.Error` instead of returning an error tuple.
## Parameters
- `data` - The data to decode (binary for JSON, string for string format; or a JSON term when `:pre_decoded` option is set)
- `module` - Module containing the type definition
- `type_ref` - Type reference (typically an atom like `:t`)
- `format` - Format to decode from (default: `:json`)
- `opts` - Options list (default: `[]`). Supported options:
- `:pre_decoded` - Accept an already-decoded JSON term as input, skipping JSON parsing.
## Returns
- `dynamic()` - 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!(dynamic(), module() | type_info(), atom() | sp_type_or_ref(), atom(), [
decode_option()
]) ::
dynamic()
def decode!(data, module, type_ref, format \\ :json, opts \\ []) do
case decode(data, module, type_ref, format, opts) do
{:ok, result} ->
result
{:error, [error | _]} ->
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_found, type_name, _arity} when type_name == 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