Packages
Automatically generate OpenAPI 3.1 specs from Phoenix JSON views and Ecto schemas — zero annotations required
Current section
Files
Jump to
Current section
Files
phoenix_spec
README.md
README.md
# PhoenixSpec
[](https://github.com/dannote/phoenix_spec/actions)
Automatically generate [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) 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:
1. **Ecto schemas** — field types (`:string`, `:integer`, `:utc_datetime`, …)
2. **JSON views** (`*JSON` modules) — which fields are exposed and how they nest
3. **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`:
```elixir
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:
```elixir
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:
```json
{
"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`:
```elixir
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`:
```elixir
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:
```elixir
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:
```json
{
"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:**
```elixir
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):
```elixir
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:
```json
{
"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:
```elixir
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:
```json
{
"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:
```typescript
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:
```typescript
// 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`:
```elixir
def project do
[
compilers: Mix.compilers() ++ [:phoenix_spec],
# ...
]
end
```
Configure in `config/dev.exs`:
```elixir
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:
```elixir
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