README.md

# PhoenixSpec

[![CI](https://github.com/dannote/phoenix_spec/actions/workflows/ci.yml/badge.svg)](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.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:

```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` |
| `: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.

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

## License

MIT