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.2.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 a 3-arity action(path_args, headers, body) convention:

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]

  spectral(summary: "Get user", description: "Returns a user by ID")
  @spec show(%{id: integer()}, %{}, nil) ::
          {200, %{}, MyApp.User.t()}
          | {404, %{}, MyApp.Error.t()}
  def show(%{id: id}, _headers, nil) 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(%{}, %{}, MyApp.User.t()) :: {201, %{}, MyApp.User.t()}
  def create(_path_args, _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(%{id: user_id()}, %{}, nil) ::
        {200, %{}, MyApp.User.t()}
        | {404, %{}, MyApp.Error.t()}
def show(%{id: id}, _headers, nil), do: ...

Typed response headers

Response headers are declared in the return type map:

@spec show(%{id: integer()}, %{}, nil) ::
        {200, %{"x-request-id": String.t()}, MyApp.User.t()}
def show(%{id: id}, _headers, nil) 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 (default: "/openapi")
:cache no Cache the generated JSON in :persistent_term (default: false)

Request/Response Behavior

Design