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.7.0"}
]
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: ...
conn(Plug.Conn.t()) — the Plug connection, for out-of-band context (conn.assigns,conn.remote_ip, etc.)path_args(map, e.g.%{id: integer()}) — path parameters declared in the router, decoded from strings to the types declared in the specquery_params(map) — query string parameters, decoded to typed values; required keys use atom syntax (key: type), optional keys use arrow syntax (optional(key) => type)headers(map) — request headers, decoded from binary strings to typed values; required keys use atom syntax (key: type), optional keys use arrow syntax (optional(key) => type)body(any Elixir type (e.g., a struct), ornil) — decoded and validated request body, ornilfor requests without a body
Note: Use
connonly for context that isn't already captured in the typed arguments — primarilyconn.assigns(auth data from upstream plugs),conn.remote_ip,conn.host, orconn.method. Do not readconn.path_params,conn.query_params,conn.req_headers, orconn.body_paramsdirectly; 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
content-type is reserved in this map: it must be a literal atom, and it declares the response's media type (see Non-JSON response bodies). The entry is validated like the headers around it, but it sets the media type instead of being emitted as a header of its own.
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
| Option | Required | Description |
|---|---|---|
:router |
yes | Your Phoenix router module |
:title |
yes | API title |
:version |
yes | API version string |
:summary |
no | Short one-line summary |
:description |
no | Longer description |
:terms_of_service |
no | URL to terms of service |
:contact |
no | Map with :name, :url, :email |
:license |
no | Map with :name and optional :url, :identifier |
:servers |
no | List of maps with :url and optional :description |
:security_schemes |
no | Map 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"}} |
:security |
no | List 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_url |
no | URL 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. |
:cache |
no | Cache the generated JSON in :persistent_term (default: false) |
Non-JSON response bodies
A response's media type is declared as a content-type entry in its response headers map, with the media type as a literal atom. PhoenixSpectral documents the body under that media type instead of application/json, and sends the bytes verbatim instead of JSON-encoding them:
@spec mandate_pdf(Plug.Conn.t(), %{id: String.t()}, %{}, %{}, nil) ::
{200, %{"content-type": :"application/pdf"}, binary()}
| {404, %{}, MyApp.Error.t()}
def mandate_pdf(_conn, %{id: id}, _query, _headers, _body) do
case MyApp.Documents.pdf(id) do
{:ok, pdf} -> {200, %{"content-type": :"application/pdf"}, pdf}
:not_found -> {404, %{}, %MyApp.Error{message: "Not found"}}
end
end
The 200 above is documented as
"200": { "description": "OK", "content": { "application/pdf": { "schema": { "type": "string" } } } }
while the 404 in the same union stays application/json — the declaration applies per response, not per endpoint.
- The media type must be a literal atom: it is read from the typespec, so it is available to the OpenAPI generator. Anything else in that position — a
String.t(), an integer, a union of two media types — raises, as does a secondcontent-typeentry: one response carries one media type. - The response carries the
content-typeheader, set from the declared media type. The entry is validated like any other declared response header — a required declaration the action omits raises, and so does a returned value that does not match the declared media type — but its value is not what gets sent, and the OpenAPI response keys the body under the media type instead of listing it as a response header. - Any other entry in the headers map keeps behaving as a typed response header.
- Under a non-JSON media type the body is sent as-is, so it must be typed
binary(); declaring anything else raises when the spec is generated, and returning a non-binary raises on the request. Anilbody type is the exception: it declares no body at all, so the response is empty and the spec carries nocontent— an alias that resolves tonilcounts as one, and returning a body under it raises.application/jsonand*+jsonbodies are still encoded by Spectral, whatever the media type's casing. - Only JSON responses get
; charset=utf-8appended. Bake a charset into the atom when a text format needs one::"text/csv; charset=utf-8"is sent verbatim and keyed verbatim in the spec. - The
%{"content-type": ...}shorthand above declares a required key, so the action returns it and Dialyzer expects it there. Write%{optional(:"content-type") => :"application/pdf"}to leave it out of the return value — the media type then comes from the typespec alone, which is what an action returningconndoes. - An action that returns
conndirectly (below) sets its owncontent-type; declaring the entry makes the generated spec describe what the action actually sends.
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, %{optional(:"content-type") => :"application/octet-stream"}, binary()}
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 — including its content-type, which belongs in the typespec too (see Non-JSON response bodies) so the generated spec matches what the action sends.
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 default | Type allows nil? |
JSON field missing → |
|---|---|---|
any non-nil value |
either | struct default used |
nil |
yes (T | nil) |
nil |
nil |
no | error — 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
- Invalid requests (type mismatch, missing required fields) return
400 Bad Requestwith a JSON error body listing the validation errors - Response encoding failures return
500 Internal Server Errorand log the error - Missing or malformed typespecs raise at runtime — actions without
@speccrash on dispatch; malformed specs crash on spec generation - Only routes whose controllers
use PhoenixSpectral.Controllerappear in the generated OpenAPI spec; standard Phoenix controllers are ignored
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 a text/vcard download endpoint whose media type is declared in the response headers map, and 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 codec |
spectral type_parameters: %{min_length: …, max_length: …, pattern: …} on a String.t() type |
String and binary constraints |
Expose camelCase (or any) JSON keys while keeping snake_case structs |
spectral field_aliases: %{first_name: "firstName"} |
Field Aliases |
Hide internal fields (e.g. password_hash) or expose different views of one struct |
spectral only: [:id, :name] |
Field Filtering with only |
| Make a body field optional / supply a default | struct defstruct defaults + nullable types |
Struct defaults |
Accept an enum from a path/query param (e.g. ?role=admin) |
an atom-union type :: :admin | :user, decoded via the binary_string format |
Data Serialization API |
Serialize DateTime, Date, or MapSet |
the built-in codecs (registered automatically) | Built-in Codecs |
| Encode/decode a domain type with custom rules (prefixed IDs, money, etc.) | use Spectral.Codec |
Custom Codecs |
Add title, description, or example payloads to a schema |
spectral title:, description:, examples_function: |
Documenting Types with spectral |
| Annotate a path/header/query parameter's description | a 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
- Typespecs are the single source of truth — no separate schema definitions;
@specdrives both docs and validation - Action convention —
(conn, path_args, query_params, headers, body)→{status, headers, body}; union return types produce multiple OpenAPI response entries - Crash on bad code, error on bad user input — malformed typespecs raise; invalid requests return 400, encoding failures return 500
- Automatic encoding/decoding — Spectral handles struct serialization
- Optional caching — via
persistent_termfor production performance