tutorial 8 min read

OpenAPI 3.0 and Swagger UI Integration in Phoenix

How Prismatic auto-generates OpenAPI 3.0 specs from Elixir typespecs, serves Swagger UI, validates requests, and manages API versioning.

Tomas Korcak (korczis) 352 words

Maintaining API documentation separately from implementation guarantees drift. The Prismatic Platform solves this by generating OpenAPI 3.0 specifications directly from Elixir typespecs and module documentation, then serving Swagger UI from the same Phoenix application. Every deployed endpoint is automatically documented with accurate schemas, examples, and error responses.

Architecture Overview

The OpenAPI generation pipeline works in three layers:

LayerResponsibilityModule
ExtractionParse @spec, @doc, @moduledoc from beam filesOpenApi.Extractor
GenerationBuild OpenAPI 3.0 JSON/YAML documentOpenApi.Generator
ServingServe Swagger UI + spec file at /api/docsOpenApi.Plug

Spec Generation from Typespecs

The generator walks all controller modules and their action functions, extracting type information to build request/response schemas:

defmodule PrismaticWeb.OpenApi.Generator do
  @moduledoc """
  Generates OpenAPI 3.0 specification from Phoenix router
  and controller typespecs.

  Scans all routes in the :api pipeline, extracts specs
  from controller actions, and builds a complete OpenAPI
  document with schemas, paths, and security definitions.
  """

  @spec generate() :: map()
  def generate do
    routes = PrismaticWeb.Router.__routes__()
    api_routes = Enum.filter(routes, &api_route?/1)

    %{
      "openapi" => "3.0.3",
      "info" => %{
        "title" => "Prismatic Platform API",
        "version" => api_version(),
        "description" => "Intelligence platform REST API"
      },
      "servers" => [
        %{"url" => "/api/v1", "description" => "Production API"}
      ],
      "paths" => build_paths(api_routes),
      "components" => %{
        "schemas" => build_schemas(api_routes),
        "securitySchemes" => security_schemes()
      },
      "security" => [%{"bearerAuth" => []}, %{"apiKey" => []}]
    }
  end

  defp build_paths(routes) do
    routes
    |> Enum.group_by(& &1.path)
    |> Enum.into(%{}, fn {path, methods} ->
      {convert_path(path), build_path_item(methods)}
    end)
  end

  defp build_path_item(methods) do
    Enum.into(methods, %{}, fn route ->
      {String.downcase(route.verb), build_operation(route)}
    end)
  end

  defp build_operation(route) do
    controller = route.plug
    action = route.plug_opts

    %{
      "operationId" => "#{inspect(controller)}.#{action}",
      "summary" => extract_doc_summary(controller, action),
      "description" => extract_doc_body(controller, action),
      "tags" => extract_tags(controller),
      "parameters" => extract_parameters(route),
      "responses" => build_responses(controller, action),
      "requestBody" => build_request_body(controller, action)
    }
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
    |> Enum.into(%{})
  end
end

Swagger UI Setup

Swagger UI is served as a static asset through a custom Plug. The spec endpoint returns the generated JSON, and the UI page loads it:

defmodule PrismaticWeb.OpenApi.Plug do
  @moduledoc """
  Serves OpenAPI spec and Swagger UI.

  Mounts at /api/docs for the UI and /api/docs/openapi.json
  for the raw specification. Caches the generated spec in
  ETS and invalidates on code reload in development.
  """

  @behaviour Plug

  alias PrismaticWeb.OpenApi.Generator

  @impl Plug
  def init(opts), do: opts

  @impl Plug
  def call(%Plug.Conn{path_info: ["api", "docs", "openapi.json"]} = conn, _opts) do
    spec = cached_spec()

    conn
    |> Plug.Conn.put_resp_content_type("application/json")
    |> Plug.Conn.send_resp(200, Jason.encode!(spec))
    |> Plug.Conn.halt()
  end

  def call(%Plug.Conn{path_info: ["api", "docs"]} = conn, _opts) do
    conn
    |> Plug.Conn.put_resp_content_type("text/html")
    |> Plug.Conn.send_resp(200, swagger_ui_html())
    |> Plug.Conn.halt()
  end

  def call(conn, _opts), do: conn

  defp cached_spec do
    case :ets.lookup(:openapi_cache, :spec) do
      [{:spec, spec}] -> spec
      [] ->
        spec = Generator.generate()
        :ets.insert(:openapi_cache, {:spec, spec})
        spec
    end
  end
end

The Swagger UI HTML template references the CDN-hosted assets and points to the local spec URL:

defp swagger_ui_html do
  """
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Prismatic API Documentation</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui.css">
  </head>
  <body>
    <div id="swagger-ui"></div>
    <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui-bundle.js"></script>
    <script>
      SwaggerUIBundle({
        url: '/api/docs/openapi.json',
        dom_id: '#swagger-ui',
        deepLinking: true,
        presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
        layout: 'StandaloneLayout'
      });
    </script>
  </body>
  </html>
  """
end

Request Validation

Incoming requests are validated against the OpenAPI spec before reaching the controller. A validation plug intercepts the request, checks path parameters, query parameters, headers, and request body against the registered schema:

defmodule PrismaticWeb.OpenApi.ValidationPlug do
  @moduledoc """
  Validates incoming API requests against the OpenAPI spec.

  Checks parameter types, required fields, enum values,
  and request body structure. Returns 422 with detailed
  error messages for invalid requests.
  """

  @behaviour Plug

  @impl Plug
  def init(opts), do: opts

  @impl Plug
  def call(conn, _opts) do
    with {:ok, operation} <- find_operation(conn),
         :ok <- validate_params(conn, operation),
         :ok <- validate_body(conn, operation) do
      conn
    else
      {:error, errors} ->
        conn
        |> Plug.Conn.put_status(422)
        |> Phoenix.Controller.json(%{
          error: "Validation failed",
          details: format_errors(errors)
        })
        |> Plug.Conn.halt()
    end
  end

  defp validate_params(conn, operation) do
    errors =
      for param <- Map.get(operation, "parameters", []),
          error = validate_single_param(conn, param),
          error != nil do
        error
      end

    if errors == [], do: :ok, else: {:error, errors}
  end

  defp validate_single_param(conn, %{"name" => name, "required" => true, "in" => "query"}) do
    case Map.get(conn.query_params, name) do
      nil -> %{param: name, error: "required query parameter missing"}
      _ -> nil
    end
  end
end
Validation TypeChecked AgainstError CodeResponse
Missing required paramrequired: true in specMISSING_PARAM422 with field name
Wrong param typeSchema type fieldTYPE_MISMATCH422 with expected type
Invalid enum valueSchema enum arrayINVALID_ENUM422 with allowed values
Body schema mismatchRequest body schemaSCHEMA_VIOLATION422 with JSON path
Missing content-typerequestBody.content keysCONTENT_TYPE415 Unsupported

API Versioning Strategy

The platform uses URL-based versioning with a fallback header strategy. The router mounts versioned scopes, and a version negotiation plug selects the appropriate handler:

# Router versioned scopes
scope "/api/v1", PrismaticWeb.Api.V1 do
  pipe_through [:api, :api_auth, :openapi_validate]
  resources "/osint/search", OsintSearchController, only: [:index, :show]
  resources "/dd/cases", DDCaseController, only: [:index, :show, :create, :update]
end

scope "/api/v2", PrismaticWeb.Api.V2 do
  pipe_through [:api, :api_auth, :openapi_validate]
  resources "/osint/search", OsintSearchController, only: [:index, :show]
end

The versioning conventions follow a deprecation schedule:

VersionStatusDeprecation DateRemoval Date
v1Stable--
v2Beta--
v0Deprecated2026-01-012026-07-01

Cache Invalidation in Development

During development, the OpenAPI spec must regenerate when code changes. The generator hooks into Phoenix's code reloader:

defmodule PrismaticWeb.OpenApi.DevReloader do
  @moduledoc """
  Invalidates cached OpenAPI spec when code changes in dev.
  Hooks into Phoenix.CodeReloader notifications.
  """

  use GenServer
  require Logger

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl GenServer
  def init(_opts) do
    if Application.get_env(:prismatic_web, :env) == :dev do
      Phoenix.PubSub.subscribe(PrismaticWeb.PubSub, "code_reload")
    end

    {:ok, %{}}
  end

  @impl GenServer
  def handle_info({:code_reload, _modules}, state) do
    Logger.info("OpenAPI spec cache invalidated after code reload")
    :ets.delete(:openapi_cache, :spec)
    {:noreply, state}
  end
end

This approach ensures the Swagger UI at /api/docs always reflects the actual implementation. No manual spec authoring, no documentation drift, and automatic request validation against the same source of truth.