PhoenixSpec
Automatically generate OpenAPI 3.1 specifications from your Phoenix JSON views and Ecto schemas. No DSL to learn, no schemas to duplicate.
How It Works
PhoenixSpec combines three sources already present in every Phoenix API:
- Ecto schemas — field types (
:string,:integer,:utc_datetime, …) - JSON views (
*JSONmodules) — which fields are exposed and how they nest - Router — routes, HTTP verbs, path parameters
Ecto schemas ──┐
├──▶ OpenAPI 3.1 spec
JSON views ────┘ │
├──▶ openapi.json / openapi.yaml
Router ─────────────────▶│
└──▶ api.d.ts (optional)
Quick Start
Add to your mix.exs:
def deps do
[
{:phoenix_spec, "~> 0.2", only: :dev, runtime: false}
]
end
Generate the spec:
mix phoenix_spec.gen
That's it. The task introspects your router, finds the JSON views, reads the Ecto schemas, and writes priv/static/openapi.json.
What Gets Inferred
Given a standard Phoenix JSON view:
defmodule MyAppWeb.PostJSON do
alias MyApp.Blog.Post
def index(%{posts: posts}) do
%{data: for(post <- posts, do: data(post))}
end
def show(%{post: post}) do
%{data: data(post)}
end
def data(%Post{} = post) do
%{
id: post.id,
title: post.title,
status: post.status,
published_at: post.published_at,
author: MyAppWeb.UserJSON.data(post.author)
}
end
end
PhoenixSpec generates:
{
"components": {
"schemas": {
"Post": {
"type": "object",
"required": ["author", "id", "published_at", "status", "title"],
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" },
"status": { "type": "string", "enum": ["draft", "published", "archived"] },
"published_at": { "type": "string", "format": "date-time" },
"author": { "$ref": "#/components/schemas/User" }
}
}
}
}
}
Automatically detected
| Pattern | OpenAPI |
|---|---|
post.title (:string in Ecto) | {type: "string"} |
post.id (primary key) | {type: "integer"} |
post.published_at (:utc_datetime) | {type: "string", format: "date-time"} |
post.status (Ecto.Enum) | {type: "string", enum: [...]} |
post.tags ({:array, :string}) | {type: "array", items: {type: "string"}} |
comment.user.name (through belongs_to) | resolved from associated schema |
UserJSON.data(post.author) | $ref to User schema |
for(c <- cs, do: CommentJSON.data(c)) | array of $ref |
%{data: for(...)} in index/1 | wrapped array response |
%{data: data(post)} in show/1 | wrapped object response |
%{name: x, email: y} inline map | inline object with typed properties |
Route get "/posts/:id" | path parameter {id} |
user.address (embeds_one) | inline object schema |
user.links (embeds_many) | array of inline objects |
if(cond, do: val) in map value | optional field (not in required) |
cast(struct, params, [:f1, :f2]) | typed request body schema |
put_status(conn, :created) | 201 response code |
send_resp(conn, :no_content, "") | 204 response code |
Multiple data/1 with different structs | oneOf schema |
Ecto type mapping
| Ecto | OpenAPI |
|---|---|
:string | string |
:integer | integer |
:float | number (format: double) |
:boolean | boolean |
:decimal | string (format: decimal) |
:id | integer |
:binary_id | string (format: uuid) |
:date | string (format: date) |
:time | string (format: time) |
:utc_datetime / :naive_datetime | string (format: date-time) |
:utc_datetime_usec / :naive_datetime_usec | string (format: date-time) |
:map | object with additionalProperties: true |
{:map, :string} | object with string-valued additional properties |
:binary | string (format: binary) |
{:array, :string} | array of string |
Ecto.Enum | string with enum values |
embeds_one | inline object with embedded schema fields |
embeds_many | array of inline object |
Optional Fields
Fields wrapped in if, unless, case, or && in the JSON view map literal are automatically marked as optional (not included in required).
You can also explicitly list optional fields with @optional:
defmodule MyAppWeb.UserJSON do
@optional [:bio, :avatar_url]
def data(%User{} = user) do
%{
id: user.id,
name: user.name,
bio: user.bio,
avatar_url: user.avatar_url
}
end
end
Field Type Annotations
Computed fields (not backed by an Ecto schema field) default to unknown. Annotate them with @field_types:
defmodule MyAppWeb.PostJSON do
@field_types reading_time: :integer, full_name: :string
def data(%Post{} = post) do
%{
id: post.id,
title: post.title,
reading_time: div(String.length(post.body), 200),
full_name: "#{post.author.first} #{post.author.last}"
}
end
end
Any Ecto type works: :string, :integer, :boolean, :float, {:array, :string}, etc.
Schema and Field Metadata
Add documentation and object constraints without duplicating inferred types:
defmodule MyAppWeb.PostJSON do
@schema_metadata description: "A blog post",
examples: [%{id: 1, title: "Hello world"}],
additionalProperties: false
@field_metadata title: [description: "Public title", examples: ["Hello world"]],
metadata: [additionalProperties: %{type: "string"}]
# Existing data/1 and action functions stay unchanged.
end
Both attributes accept keyword lists with description (string), examples
(list of JSON values), and additionalProperties (boolean or schema map).
Use literal values, lists, and maps; expressions and function calls are not
executed and raise an error. Unsupported metadata keys also raise an error.
If Elixir warns that an attribute is unused, register it before assigning it:
Module.register_attribute(__MODULE__, :field_metadata, persist: true)
(and likewise for :schema_metadata).
Field keys refer to the output names in the JSON view. Metadata augments
inferred types, references, arrays, and embedded objects without changing which
fields are required. It applies across all data/1 clauses; for polymorphic
schemas, schema-level additionalProperties is applied to each oneOf variant.
Descriptions and examples remain on the enclosing schema. Nested object constraints
can be expressed with a schema map in additionalProperties.
Plain Ecto :map fields explicitly allow additional properties. Other generated
objects remain open by default (omitting additionalProperties also permits extra
keys). Set additionalProperties: false explicitly to close an object; use this
on object schemas, not array or scalar fields. For a referenced object, put the
constraint on the referenced view's @schema_metadata: additionalProperties
does not recognize properties declared through a sibling $ref.
Schema examples uses the OpenAPI 3.1 JSON Schema array form, not named media-type
examples. These annotations describe response schemas; controller/action-level
parameter, request, and response metadata is not yet supported. Documentation
metadata does not change TypeScript output.
Embedded Schemas
embeds_one and embeds_many are rendered as inline object schemas:
{
"address": {
"type": "object",
"required": ["city", "street", "zip"],
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zip": { "type": "string" }
}
},
"social_links": {
"type": "array",
"items": {
"type": "object",
"properties": {
"platform": { "type": "string" },
"url": { "type": "string" }
}
}
}
}
Response Status Codes
PhoenixSpec infers status codes from your controller source:
| Pattern | Status |
|---|---|
put_status(conn, :created) | 201 |
send_resp(conn, :no_content, "") | 204 |
create action (default) | 201 |
delete action (default) | 204 |
| Everything else | 200 |
Custom status codes set via put_status or send_resp take precedence over defaults.
Error responses are added automatically:
| Action | Error responses |
|---|---|
show, update, delete | 404 Not Found |
create, update | 422 Unprocessable Entity with error schema |
Request Bodies
PhoenixSpec detects Ecto.Changeset.cast/3 calls to build typed request body schemas. It handles two patterns:
Direct cast in controller:
def create(conn, %{"post" => post_params}) do
%Post{} |> Ecto.Changeset.cast(post_params, [:title, :body, :published])
end
Delegation via context module (standard phx.gen.json pattern):
def create(conn, %{"post" => post_params}) do
with {:ok, %Post{} = post} <- Blog.create_post(post_params) do ...
When the controller references a %Post{} struct, PhoenixSpec looks up Post.changeset/2 to find the cast/3 field list. Both patterns produce a nested request body matching Phoenix conventions:
{
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["post"],
"properties": {
"post": {
"type": "object",
"required": ["body", "published", "title"],
"properties": {
"title": { "type": "string" },
"body": { "type": "string" },
"published": { "type": "boolean" }
}
}
}
}
}
}
}
}
Polymorphic Views
When data/1 has multiple clauses matching different structs, PhoenixSpec generates a oneOf schema:
defmodule MyAppWeb.MessageJSON do
def data(%TextMessage{} = msg) do
%{id: msg.id, text: msg.text, sender: msg.sender}
end
def data(%ImageMessage{} = msg) do
%{id: msg.id, url: msg.url, width: msg.width, height: msg.height, sender: msg.sender}
end
end
Generates:
{
"Message": {
"oneOf": [
{
"type": "object",
"properties": {
"id": { "type": "integer" },
"text": { "type": "string" },
"sender": { "type": "string" }
}
},
{
"type": "object",
"properties": {
"id": { "type": "integer" },
"url": { "type": "string" },
"width": { "type": "integer" },
"height": { "type": "integer" },
"sender": { "type": "string" }
}
}
]
}
}
In TypeScript, this becomes a union type:
export interface MessageVariant1 { id: number; text: string; sender: string; }
export interface MessageVariant2 { id: number; url: string; width: number; height: number; sender: string; }
export type Message = MessageVariant1 | MessageVariant2;
TypeScript Output
Generate TypeScript type definitions instead of (or alongside) OpenAPI:
mix phoenix_spec.gen --format ts --output priv/static/api.d.ts
Produces:
// Generated by phoenix_spec — do not edit
export interface Post {
id: number;
title: string;
status: ("draft" | "published" | "archived");
published_at: string;
author: User;
}
export interface User {
id: number;
name: string;
email: string;
}
Mix Compiler
Auto-regenerate on file changes by adding the compiler to your mix.exs:
def project do
[
compilers: Mix.compilers() ++ [:phoenix_spec],
# ...
]
end
Configure in config/dev.exs:
config :phoenix_spec,
router: MyAppWeb.Router,
output: "priv/static/openapi.json",
format: "json",
title: "My API",
description: "Public API documentation",
version: "1.0.0"
Or generate multiple outputs at once:
config :phoenix_spec,
router: MyAppWeb.Router,
outputs: [
{"priv/static/openapi.json", "json"},
{"priv/static/api.d.ts", "ts"}
]
Options
mix phoenix_spec.gen \
--router MyAppWeb.Router \
--output priv/static/openapi.json \
--title "My API" \
--version 2.0.0 \
--format json
| Flag | Default | Description |
|---|---|---|
--router | Auto-detected | Router module |
--output | priv/static/openapi.json | Output file path |
--title | App name | API title in the spec |
--description | Omitted | API description (info.description) |
--version | 1.0.0 | API version |
--format | json | json, yaml, or ts |
License
MIT