Automatically generate OpenAPI 3.1 specifications from your Phoenix JSON views and Ecto schemas. No DSL to learn, no schemas to duplicate.
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)
Add to your mix.exs:
def deps do
[
{:phoenix_spec, "~> 0.1", only: :dev, runtime: false}
]
endGenerate 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.
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
endPhoenixSpec 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" }
}
}
}
}
}| 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 | 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 |
: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 |
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
endComputed 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
endAny Ecto type works: :string, :integer, :boolean, :float, {:array, :string}, etc.
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" }
}
}
}
}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 |
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])
endDelegation 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" }
}
}
}
}
}
}
}
}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
endGenerates:
{
"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;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;
}Auto-regenerate on file changes by adding the compiler to your mix.exs:
def project do
[
compilers: Mix.compilers() ++ [:phoenix_spec],
# ...
]
endConfigure in config/dev.exs:
config :phoenix_spec,
router: MyAppWeb.Router,
output: "priv/static/openapi.json",
format: "json",
title: "My API",
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"}
]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 |
--version |
1.0.0 |
API version |
--format |
json |
json, yaml, or ts |
MIT