PhoenixSpectral

PhoenixSpectral integrates Spectral with Phoenix, making controller typespecs the single source of truth for OpenAPI 3.1 spec generation and request/response validation. Define your types once — PhoenixSpectral derives the API docs and enforces them at runtime.

Most of the power lives in Spectral. PhoenixSpectral is a thin Phoenix adapter; the type system that shapes and validates your requests and responses is Spectral. Features like string/length/pattern constraints, camelCase field aliases, custom codecs, and the built-in date/time codecs are configured on your types via Spectral, not here. Read the Spectral docs and the Going further with Spectral section below before assuming a capability is missing.

Installation

Add phoenix_spectral to your dependencies in mix.exs:

def deps do
[
{:phoenix_spectral, "~> 0.6.1"}
]
end

Usage

Step 1: Define typed structs with Spectral

Spectral is an Elixir library that validates, decodes, and encodes data according to your @type definitions. Add use Spectral to a module and your types become the schema — PhoenixSpectral reads them to validate requests, decode inputs, encode responses, and generate the OpenAPI spec.

defmodule MyApp.User do
use Spectral
defstruct [:id, :name, :email]
spectral(title: "User", description: "A user resource")
@type t :: %__MODULE__{
id: integer(),
name: String.t(),
email: String.t()
}
end
defmodule MyApp.Error do
use Spectral
defstruct [:message]
spectral(title: "Error")
@type t :: %__MODULE__{message: String.t()}
end

Step 2: Create a typed controller

use PhoenixSpectral.Controller replaces the standard Phoenix action(conn, params) convention with five typed arguments. The four request inputs are kept separate rather than merged into one params map: the body can be a typed struct, which cannot be merged into a flat map alongside path args and query params without losing its type, and the OpenAPI generator needs to know where each field comes from — path, query, header, or body — to produce a correct spec.

@spec update(Plug.Conn.t(), %{id: integer()}, %{}, %{}, MyApp.User.t()) ::
{200, %{}, MyApp.User.t()}
| {404, %{}, MyApp.Error.t()}
def update(_conn, %{id: id}, _query_params, _headers, body), do: ...

Note: Use conn only for context that isn't already captured in the typed arguments — primarily conn.assigns (auth data from upstream plugs), conn.remote_ip, conn.host, or conn.method. Do not read conn.path_params, conn.query_params, conn.req_headers, or conn.body_params directly; use the decoded and validated arguments instead.

Actions return {status_code, response_headers, response_body}. Union return types produce multiple OpenAPI response entries.

Use the spectral/1 macro to annotate actions with OpenAPI metadata such as summary and description:

defmodule MyAppWeb.UserController do
use PhoenixSpectral.Controller, formats: [:json] # opts forwarded to use Phoenix.Controller
spectral(summary: "Get user", description: "Returns a user by ID")
@spec show(Plug.Conn.t(), %{id: integer()}, %{}, %{}, nil) ::
{200, %{}, MyApp.User.t()}
| {404, %{}, MyApp.Error.t()}
def show(_conn, %{id: id}, _query, _headers, _body) do
case MyApp.Users.get(id) do
{:ok, user} -> {200, %{}, user}
:not_found -> {404, %{}, %MyApp.Error{message: "User not found"}}
end
end
spectral(summary: "Create user")
@spec create(Plug.Conn.t(), %{}, %{}, %{}, MyApp.User.t()) :: {201, %{}, MyApp.User.t()}
def create(_conn, _path_args, _query, _headers, body) do
{201, %{}, MyApp.Users.insert!(body)}
end
end

Parameter descriptions

To add a description to a path or header parameter in the OpenAPI output, define a named type alias and annotate it with spectral:

spectral(description: "The user's unique identifier")
@type user_id :: integer()
@spec show(Plug.Conn.t(), %{id: user_id()}, %{}, %{}, nil) ::
{200, %{}, MyApp.User.t()}
| {404, %{}, MyApp.Error.t()}
def show(_conn, %{id: id}, _query, _headers, _body), do: ...

Typed response headers

Response headers are declared in the return type map:

@spec show(Plug.Conn.t(), %{id: integer()}, %{}, %{}, nil) ::
{200, %{"x-request-id": String.t()}, MyApp.User.t()}
def show(_conn, %{id: id}, _query, _headers, _body) do
{200, %{"x-request-id": "abc123"}, MyApp.Users.get!(id)}
end

Step 3: Serve the OpenAPI spec

defmodule MyAppWeb.OpenAPIController do
use PhoenixSpectral.OpenAPIController,
router: MyAppWeb.Router,
title: "My API",
version: "1.0.0"
end

Add routes in your router:

scope "/api" do
get "/users/:id", MyAppWeb.UserController, :show
post "/users", MyAppWeb.UserController, :create
get "/openapi", MyAppWeb.OpenAPIController, :show
get "/swagger", MyAppWeb.OpenAPIController, :swagger
end

GET /openapi returns the OpenAPI JSON spec. GET /swagger serves a Swagger UI page.

OpenAPIController options

OptionRequiredDescription
:routeryesYour Phoenix router module
:titleyesAPI title
:versionyesAPI version string
:summarynoShort one-line summary
:descriptionnoLonger description
:terms_of_servicenoURL to terms of service
:contactnoMap with :name, :url, :email
:licensenoMap with :name and optional :url, :identifier
:serversnoList of maps with :url and optional :description
:security_schemesnoMap of named Security Scheme Objects, emitted under components.securitySchemes. This is what makes Swagger UI render the Authorize button. E.g. %{"api_key" => %{type: "apiKey", in: "header", name: "x-api-key"}}
:securitynoList of Security Requirement Objects applied as the global default to every operation, e.g. [%{"api_key" => []}]. Each key should name a scheme from :security_schemes (the list holds required scopes, empty for apiKey/http). Passed through to the spec as-is — not validated by PhoenixSpectral
:openapi_urlnoURL path for the JSON spec, used by Swagger UI. Defaults to the path of this controller's :show route as declared in the router (scope prefixes included). Set explicitly to use a different path.
:cachenoCache the generated JSON in :persistent_term (default: false)

Streaming and raw responses

An action can return a Plug.Conn directly instead of {status, headers, body}. This enables send_file/3, send_chunked/2, and any other conn-based response mechanism:

@spec download(Plug.Conn.t(), %{id: String.t()}, %{}, %{}, nil) :: {200, %{}, nil}
def download(conn, %{id: id}, _query, _headers, _body) do
path = MyApp.Files.path_for(id)
conn
|> put_resp_content_type("application/octet-stream")
|> send_file(200, path)
end

When a conn is returned, PhoenixSpectral passes it through without schema validation. The typespec still documents the endpoint for the OpenAPI spec, but the actual response is your responsibility.

Ecto schemas

PhoenixSpectral can work directly with Ecto schema structs. Two features in Spectral make this practical: struct defaults and field filtering with only.

Struct defaults

When decoding a JSON request body into a struct, fields absent from the JSON are filled from the struct's defstruct defaults — the same values you get from %MyStruct{}. Whether a field is required or optional in the JSON depends on its default and type:

Struct defaultType allows nil?JSON field missing →
any non-nil valueeitherstruct default used
nilyes (T | nil)nil
nilnoerror — field is required

This means Ecto fields declared with timestamps() (which default to nil) are handled automatically: they are optional on input (the client omits them) and omitted from output when nil. No special handling required.

Field filtering with only

The only option restricts which struct fields appear in encode, decode, and schema generation. Fields not in the list are silently dropped on encode and filled from struct defaults on decode. This is the Ecto equivalent of @derive {Jason.Encoder, only: [...]}.

defmodule MyApp.User do
use Ecto.Schema
use Spectral
schema "users" do
field :name, :string
field :email, :string
field :password_hash, :string
has_many :posts, MyApp.Post
timestamps()
end
# Expose only name and email. password_hash, posts association, and
# timestamps are excluded — Spectral never tries to encode or decode them.
spectral only: [:name, :email]
@type public_t :: %__MODULE__{
name: String.t() | nil,
email: String.t() | nil,
password_hash: String.t() | nil,
posts: term(),
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}
end

You can define multiple types on the same module for different API views — for example a create_t for write input and a response_t for read output, each with a different only list.

only without Ecto

only is also useful on plain Elixir structs to expose different views of the same struct or to keep a single struct for both internal and external use:

defmodule User do
use Spectral
defstruct [:id, :name, :email, :password_hash]
@type t :: %User{id: pos_integer(), name: String.t(), email: String.t(), password_hash: binary() | nil}
spectral only: [:id, :name, :email]
@type public_t :: %User{id: pos_integer(), name: String.t(), email: String.t(), password_hash: binary() | nil}
end

The example app in example/ demonstrates both features: User uses only to hide password_hash, and UserInput uses a nil struct default to make email optional in the request body.

Request/Response Behavior

Example

The example/ directory contains a complete runnable Phoenix app demonstrating a CRUD user API with path parameters, typed request headers, union return types, and an OpenAPI/Swagger UI endpoint. It also shows two authentication styles side by side: an x-api-key header (an apiKey security scheme validated from the controller typespec) and a Bearer token (an http/bearer security scheme verified by a plug, Example.BearerAuth, that strips the Bearer prefix at runtime). Both surface in Swagger UI's Authorize dialog. To run it:

cd example
mix deps.get
make integration-test # runs the ExUnit suite in-process

Configuration

PhoenixSpectral delegates encoding, decoding, and schema generation to Spectral / spectra. Configure them directly in config/config.exs (or config/runtime.exs).

Custom codecs

Spectral automatically registers its built-in codecs (Spectral.Codec.DateTime, Spectral.Codec.Date, Spectral.Codec.MapSet, Spectral.Codec.String) at application startup — no configuration needed.

To register application-level custom codecs, or to override a built-in, add them under the :spectra application:

# config/config.exs
config :spectra, :codecs, %{
{MyApp.Money, {:type, :t, 0}} => MyApp.Codec.Money
}

The key is {ModuleOwningType, {:type, type_name, arity}}. User-configured codecs always take precedence over built-ins. See the Spectral codec guide for writing your own codecs with use Spectral.Codec.

Production: enable the module types cache

By default, spectra extracts type info from BEAM metadata on every decode/encode call. In production, enable persistent-term caching to avoid that overhead:

# config/prod.exs
config :spectra, :use_module_types_cache, true

This stores __spectra_type_info__/0 results in :persistent_term after the first call. Safe whenever modules are not hot-reloaded (i.e., in Mix releases). Clear manually with spectra_module_types:clear(Module) if needed.

Unicode validation

spectra skips Unicode validation of list-based strings by default. Enable it when strict validation matters:

config :spectra, :check_unicode, true

Going further with Spectral

PhoenixSpectral only wires Phoenix to Spectral — it adds no validation or schema features of its own. Everything below is a Spectral feature you configure on your types; PhoenixSpectral then applies it automatically to request decoding, response encoding, and the generated OpenAPI spec. This list is a map, not the full manual — follow the links into the Spectral docs for the details.

Want to…Use Spectral's…Docs
Constrain a string's length or shape (min/max length, regex pattern, format) without writing a codecspectral type_parameters: %{min_length: …, max_length: …, pattern: …} on a String.t() typeString and binary constraints
Expose camelCase (or any) JSON keys while keeping snake_case structsspectral field_aliases: %{first_name: "firstName"}Field Aliases
Hide internal fields (e.g. password_hash) or expose different views of one structspectral only: [:id, :name]Field Filtering with only
Make a body field optional / supply a defaultstruct defstruct defaults + nullable typesStruct defaults
Accept an enum from a path/query param (e.g. ?role=admin)an atom-union type :: :admin | :user, decoded via the binary_string formatData Serialization API
Serialize DateTime, Date, or MapSetthe built-in codecs (registered automatically)Built-in Codecs
Encode/decode a domain type with custom rules (prefixed IDs, money, etc.)use Spectral.CodecCustom Codecs
Add title, description, or example payloads to a schemaspectral title:, description:, examples_function:Documenting Types with spectral
Annotate a path/header/query parameter's descriptiona named type alias with spectral description: …Parameter descriptions (above)

For example, length and pattern validation needs no controller code at all — declare the constraint on the type and PhoenixSpectral enforces it on every request and advertises it in the OpenAPI schema:

defmodule MyApp.Types do
use Spectral
spectral type_parameters: %{min_length: 3, max_length: 30, pattern: "^[a-z0-9_]+$"}
@type username :: String.t()
end
# A request body field typed as username() now rejects "ab" or "Bad Name" with a 400,
# and the OpenAPI schema shows minLength/maxLength/pattern.

If you reach for conn.body_params or hand-roll validation in a controller, stop and check this table first — Spectral almost certainly does it declaratively.

Design