Current section
Files
Jump to
Current section
Files
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