Phoenix has no built-in OpenAPI output — there's no equivalent of FastAPI's automatic /openapi.json. open_api_spex is the standard fix for Plug and Phoenix apps: you declare an ApiSpec module and an operation block per controller action, and the same declarations that produce the spec can also validate incoming requests against it. From there, a Phoenix OpenAPI SDK is the same two-step problem as any other framework — get a clean spec out, then run it through a generator or a hosted pipeline like Sourced.
Getting an OpenAPI spec out of Phoenix with open_api_spex
| Step | Code / file | What it does |
|---|---|---|
| 1. Add the dependency | {:open_api_spex, "~> 3.21"} in mix.exs |
Installs the library |
| 2. Define the top-level spec | An ApiSpec module implementing the OpenApiSpex.OpenApi behaviour, populating servers, info, and paths: Paths.from_router(Router) |
Builds the root OpenAPI document from your router |
| 3. Declare operations | use OpenApiSpex.ControllerSpecs plus an operation macro call per controller action |
Documents each action's parameters, request body, and responses |
| 4. Serve the spec | plug OpenApiSpex.Plug.PutApiSpec, module: MyAppWeb.ApiSpec in the pipeline, then get "/openapi", OpenApiSpex.Plug.RenderSpec, [] in the router |
Exposes the spec as JSON at a route you choose |
| 5. Write it to disk (optional) | mix openapi.spec.json --spec MyAppWeb.ApiSpec |
Generates openapi.json without booting a server long-term |
A controller action with an operation declaration looks like this:
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias MyAppWeb.Schemas.{UserParams, UserResponse}
tags ["users"]
operation :update,
summary: "Update user",
parameters: [
id: [in: :path, description: "User ID", type: :integer, example: 1001]
],
request_body: {"User params", "application/json", UserParams},
responses: [
ok: {"User response", "application/json", UserResponse}
]
def update(conn, %{"id" => id}) do
json(conn, %{data: %{id: id, name: "joe user"}})
end
end
Add :open_api_spex to import_deps in .formatter.exs so the formatter doesn't add parentheses to the macro calls.
Serving Swagger UI
Once the spec route is live, OpenApiSpex.Plug.SwaggerUI serves an interactive UI pointed at it — get "/swaggerui", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi" in a browser-pipeline scope. Its JavaScript and CSS assets load from cdnjs.cloudflare.com rather than being vendored into the package.
Gotcha 1: operation_id is optional, and the inferred default is undocumented for hand-written specs
The operation macro accepts an operation_id key (snake_case, per open_api_spex's own naming convention — more on that below), but nothing requires you to set it. The README's request-validation section shows operation_id: "UserController.create" as the format used to match a request to its operation for Phoenix apps that skip explicit operation_id, inferred from conn.private — but that's documented for OpenApiSpex.Plug.CastAndValidate's matching, not as a guarantee for what ends up in the OpenAPI document's operationId field. Set operation_id explicitly in every operation block if you want a stable, chosen name rather than relying on inference. See how to fix missing operationId errors for what an absent operationId does to generated SDK method names.
Gotcha 2: field names are snake_case, not the OpenAPI standard camelCase
open_api_spex documents this directly: "the names of the OpenAPI fields follow snake_case naming convention instead of OpenAPI's (and JSON Schema's) camelCase convention" in your Elixir source (operation_id, not operationId). The library converts this to standard camelCase JSON when it renders the spec, so the output is spec-compliant — but if you're hand-inspecting the generated JSON against your Elixir source, or writing a script that greps for operationId in your controller files, the mismatch is worth knowing about up front.
From a Phoenix spec to hosted docs and typed SDKs
Once your ApiSpec module and controller operations produce a clean document — every action covered, explicit operation_ids — turning it into an installable SDK is a separate step. With Sourced:
- Point Sourced at your repo or the generated file. Connect from GitHub, or upload the
openapi.jsonthatmix openapi.spec.jsonwrote directly. - Preview the TypeScript and Python SDKs. Method names and typed models render before anything publishes, so an inferred or missing operation name is visible in preview, not a customer's autocomplete.
- Get hosted docs and an llms.txt from the same pass. No separate docs deploy — the hosted docs preview and llms.txt come from the same spec.
- Publish on your schedule. Free unlimited previews and up to 2 hosted noindex docs review sites and one hosted MCP server, no credit card, cover steps 1-3.
Comparison: open_api_spex vs. a full SDK pipeline
| Need | open_api_spex gives you | What it doesn't give you |
|---|---|---|
| A generated OpenAPI spec | Yes, from controller operation declarations |
Nothing — this part is real and free |
| Request validation synced with docs | Yes — CastAndValidate uses the same operation specs |
Nothing to add here |
| Interactive API browsing | OpenApiSpex.Plug.SwaggerUI |
A hosted docs site for customers |
| A typed client SDK | Nothing built in | Generated TypeScript or Python SDK |
| A diff before a breaking release | Nothing built in | A compatibility report |
| Agent-readable docs | Nothing built in | llms.txt derived from the spec |
OSS generators for other languages
Sourced's generated SDKs focus on TypeScript and Python today — the two ecosystems where teams publish first, not Elixir. For the SDK itself, a clean open_api_spex document works with the standard per-language OpenAPI Generator ecosystem — Go, Java, Ruby, PHP, C#, Kotlin, Rust, and Swift all have their own walkthroughs. Sourced still fits on that same document for hosted docs, llms.txt, and validation. The five-minute generate-an-SDK guide covers running a local generator against any spec.
Other backend frameworks with their own spec-extraction paths: FastAPI, Rails, NestJS, Express, Django, Spring Boot, Laravel, and .NET.
Honest scope: when open_api_spex alone is enough
If your Phoenix API only has internal Elixir consumers, OpenApiSpex.Plug.CastAndValidate doing runtime request validation plus the SwaggerUI plug for reference is a genuinely complete setup — you don't need a codegen pipeline on top of it. open_api_spex is also only as good as the operations you've declared: a controller action with no operation block (or an explicit operation :action, false) is simply absent from the generated spec, the same silent-gap failure mode as any documentation tool driven by opt-in annotations rather than route scanning. Reach for full SDK generation once you have external customers, a second language to support, or a docs site that needs to outlive an internally-hosted SwaggerUI.
FAQ
Does Phoenix generate OpenAPI automatically?
No. Phoenix has no built-in OpenAPI output. open_api_spex is the standard library for both Plug and Phoenix apps — it builds the spec from operation declarations on your controllers, not by scanning routes automatically.
What's the Mix task to write the OpenAPI file to disk?
mix openapi.spec.json --spec MyAppWeb.ApiSpec (or openapi.spec.yaml with the ymlr dependency added). It boots the application by default; pass --start-app=false to skip that.
Does open_api_spex support OpenAPI 3?
Yes — its own description states it leverages "Open API Specification 3 (formerly Swagger)." Field names in your Elixir source are snake_case, but the library renders standard camelCase OpenAPI 3 JSON.
Why does my generated SDK have an unexpected or missing operation name?
Most likely operation_id wasn't set explicitly in that action's operation block. Set it directly — the documented inference behavior (conn.private, in a ControllerName.action shape) is described for request-validation matching, not guaranteed as your spec's method-naming source.
Can open_api_spex validate requests against the spec it generates?
Yes. Add plug OpenApiSpex.Plug.CastAndValidate to a controller (after PutApiSpec is in the pipeline) to validate and cast incoming parameters using the same operation definitions that produce the OpenAPI document.
Does an action need an operation block to appear in the spec?
Yes. Generation is driven by the operation macro calls you write, not by scanning your router or controllers. An action with no operation block, or with operation :action, false, doesn't appear in the generated document.
Ship it
Once your ApiSpec module and controller operations produce a clean document, you have a real Phoenix OpenAPI SDK source to work with. Start free on Sourced to preview a TypeScript and Python SDK plus hosted docs from that spec in one pass, or run it through the OpenAPI validator first to catch gaps before you generate anything.