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) - `only` - List of field name atoms to include when encoding, decoding, and generating schemas. Fields not in the list are silently dropped. For Elixir structs, excluded fields are filled from the struct's defaults on decode. ## 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) - `only` - List of field name atoms to include when encoding, decoding, and generating schemas. Fields not in the list are silently dropped. For Elixir structs, excluded fields are filled from the struct's defaults on decode. ## 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) || [] 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) # Merge spectral annotations with type/spec declarations, sort by line, then do a # single left-to-right scan. Each spectral sets a pending doc that is consumed by # the next declaration — O(N log N) vs the previous O(N²) Enum.find approach. all_items = (Enum.map(spectral_attrs, fn {line, doc} -> {line, :spectral, doc} end) ++ types_with_lines ++ specs_with_lines) |> Enum.sort_by(fn {line, _, _} -> line end) {pairs_reversed, leftover} = Enum.reduce(all_items, {[], nil}, fn {line, :spectral, doc}, {pairs, _pending} -> # New spectral replaces any previous pending (last one before a declaration wins) {pairs, {line, doc}} {_line, _kind, _ref}, {pairs, nil} -> # No pending spectral — this declaration is undocumented {pairs, nil} {_line, kind, ref}, {pairs, {_spectral_line, doc}} -> # Consume the pending spectral {[{kind, ref, doc} | pairs], nil} end) if leftover != nil do {spectral_line, _doc} = leftover raise ArgumentError, "spectral call on line #{spectral_line} in #{inspect(env.module)} has no corresponding @type or @spec definition after it" end paired_docs = Enum.reverse(pairs_reversed) 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, doc1} = Map.pop(doc, :type_parameters) {only, doc_clean} = Map.pop(doc1, :only) updated_type = existing_type |> then(fn t -> if only != nil, do: :spectra_abstract_code.apply_only(t, only), else: t end) |> :spectra_type.add_doc_to_type(doc_clean) |> 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