Skip to content

dannote/phoenix_spec

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

23 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PhoenixSpec

CI

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:

  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:

def deps do
  [
    {:phoenix_spec, "~> 0.1", 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
: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.

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",
  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
--version 1.0.0 API version
--format json json, yaml, or ts

License

MIT

About

Generate OpenAPI 3.1 specs from Phoenix JSON views and Ecto schemas

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages