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.

Installation

Add phoenix_spectral to your dependencies in mix.exs:

def deps do
  [
    {:phoenix_spectral, "~> 0.3.3"}
  ]
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

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
: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)

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.

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. To run it:

cd example
mix deps.get
make integration-test   # starts server, runs curl checks, stops server

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 ships codecs for DateTime, Date, and MapSet that are not active by default. Register them — and any application-level custom codecs — under the :spectra application:

# config/config.exs
config :spectra, :codecs, %{
  {DateTime, {:type, :t, 0}} => Spectral.Codec.DateTime,
  {Date,     {:type, :t, 0}} => Spectral.Codec.Date,
  {MapSet,   {:type, :t, 1}} => Spectral.Codec.MapSet
}

The key is {ModuleOwningType, {:type, type_name, arity}}. 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

Design