Packages

OpenAPI documentation for Phoenix built from what your code already says: typespecs, docs and routes.

Current section

Files

Jump to
portolan README.md
Raw

README.md

<p align="center">
<img src="assets/logo.png" alt="Portolan Logo" width="160">
</p>
# Portolan
[![Hex Package](https://img.shields.io/hexpm/v/portolan.svg)](https://hex.pm/packages/portolan)
[![Hex Docs](https://img.shields.io/badge/hex-docs-purple.svg)](https://hexdocs.pm/portolan)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/altenwald/portolan/blob/main/LICENSE)
> A *portolan* was a medieval nautical chart describing ports and the routes
> between them. Portolan does the same for your Phoenix API.
Portolan builds the [OpenAPI](https://www.openapis.org) document of a
Phoenix application from what the code already says:
* the **router** gives the paths, verbs and actions
* **`@spec`** gives the parameters and responses of every action
* **`@type`** gives the schemas
* **`@moduledoc`**, **`@doc`** and **`@typedoc`** give the descriptions
* **`@deprecated`** marks deprecated operations
There is no extra DSL to learn and nothing to keep in sync. If something
needed for the document is missing, such as an action without `@spec` or a
type that cannot be represented in JSON, the build fails with a compiler
diagnostic pointing to the exact file and line.
> **Status:** under active development. The API may change before 1.0.
## How it looks
```elixir
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
@typedoc """
Parameters to fetch a user.
* `id` - the user identifier
* `include` - related data to embed in the response
"""
@type show_params :: %{
required(:id) => Ecto.UUID.t(),
optional(:include) => [:roles | :teams]
}
@doc """
Fetches a user.
Returns the user with the requested related data.
"""
@spec show(Plug.Conn.t(), show_params()) :: {:ok, MyApp.User.t()} | {:error, :not_found}
def show(_conn, %{id: id} = params) do
MyApp.Accounts.fetch_user(id, params[:include] || [])
end
end
```
From this Portolan knows that `GET /users/{id}` takes a UUID in the path
and an optional `include` query parameter, that it answers `200` with a
`MyApp.User` or `404`, and how to describe all of it.
The same description is used at runtime: `params` arrives with atom keys
and typed values, invalid parameters are answered with `422` before the
action runs, and the action returns its result instead of building the
response. The documentation can never disagree with the validation.
## Setup
The [getting started guide](guides/getting-started.md) goes through every
step in a Phoenix 1.7 or 1.8 application, and the
[embedding guide](guides/embedding-scalar.md) shows the documentation
inside a page of your application. In short:
Add the Portolan compiler after the default ones in `mix.exs`:
```elixir
def project do
[
compilers: Mix.compilers() ++ [:portolan],
# ...
]
end
```
Tell it which router describes the API in `config/config.exs`:
```elixir
config :my_app, Portolan,
router: MyAppWeb.Router,
pages: ["docs/authentication.md"]
```
And add `use Portolan.Controller`, after `use Phoenix.Controller`, to every
controller of the API. Other controllers, such as the ones rendering HTML,
are left out of the document.
In development, add `:portolan` to the reloadable compilers of the
endpoint, so the code reloader keeps everything up to date:
```elixir
config :my_app, MyAppWeb.Endpoint,
reloadable_compilers: [:elixir, :app, :portolan]
```
Serve the document and its interface, written next to it, with the
`Plug.Static` of the endpoint:
```elixir
plug Plug.Static, at: "/", from: :my_app, only: ~w(assets openapi.json openapi.html)
```
The interface is [Scalar](https://scalar.com) by default, and it can be
[Swagger UI](https://swagger.io/tools/swagger-ui/) with `ui: :swagger_ui`,
or none with `ui: false`. It is loaded from the jsDelivr CDN, with pinned
versions and subresource integrity. To serve it from the application
instead, without depending on the CDN, install a copy with
`mix portolan.ui.install`, commit it, and set `ui_assets: :local`. See
`Portolan.UI`.
The document is written to `priv/static/openapi.json` on every compilation,
and the contracts used to cast parameters at runtime to
`priv/portolan/contracts.etf`, which can be ignored by version control.
See `Mix.Tasks.Compile.Portolan` for all the options, including the OpenAPI
version (`"3.1"` by default, or `"3.2"`).
## Where the information comes from
| OpenAPI | Source |
| ----------------------------- | --------------------------------------------------------- |
| API title and version | the `:name` and `:version` of the project |
| API description | the `@moduledoc` of the router |
| Paths and methods | the routes of the router |
| Tags | the controllers and their `@moduledoc` |
| Summary and description | the first paragraph and the rest of the action `@doc` |
| Deprecated operations | `@deprecated` or `@doc deprecated: "..."` |
| Parameters and request body | the second argument of the action `@spec` |
| Responses | the return type of the action `@spec` |
| Schemas | the referenced `@type`s and their `@typedoc` |
| Documentation pages | the Markdown files in the `:pages` option |
### Parameters
The second argument of the spec is a map type. Keys that appear in the
route path are path parameters. The rest are query parameters for `GET`,
`HEAD`, `DELETE` and `OPTIONS`, and the JSON body for the other methods.
Fields can be documented in the `@typedoc` with a list where each item
starts with the field name between backticks. It is optional, but
documenting a field that does not exist is an error.
### Responses
| Return type | Response |
| ------------------------------ | -------------------------------------------- |
| `{:ok, data}` | `200` with `data` |
| `{:created, data}` | `201` with `data`, the same for any status |
| `:no_content` | `204` without body, the same for any status |
| `{:error, :not_found}` | `404` with an error body |
| `{:error, Ecto.Changeset.t()}` | `422` with the validation errors |
Statuses are the atoms known by `Plug.Conn.Status`. Actions with documented
parameters also answer `422` when the parameters are not valid. See
`Portolan.Response` for the bodies of errors.
Actions written the classic way, receiving `map()` or returning
`Plug.Conn.t()`, keep working, but Portolan cannot know what they receive
or answer, so they are documented without that information and a warning
is reported.
## Diagnostics
Anything needed for the document that is missing or cannot be represented
is reported as a compiler diagnostic, with the file and line to fix:
```text
error: MyAppWeb.UserController.show/2 needs a @spec to be documented
lib/my_app_web/controllers/user_controller.ex:42
error: term() accepts any value and cannot be documented, use a more specific type
lib/my_app/accounts/user.ex:12
warning: the response of MyAppWeb.PageController.export/2 is not documented, return {:ok, data} or {:error, reason} instead of Plug.Conn.t()
lib/my_app_web/controllers/page_controller.ex:30
```
Errors stop the compilation. Actions and controllers documented with
`@doc false` or `@moduledoc false` are left out of the document.
## Types
Typespecs are translated into JSON Schema (the dialect used by OpenAPI 3.1
and 3.2):
| Typespec | JSON Schema |
| ----------------------------------------- | ----------------------------------------------- |
| `String.t()`, `binary()` | `{"type": "string"}` |
| `Ecto.UUID.t()` | `{"type": "string", "format": "uuid"}` |
| `Date.t()`, `DateTime.t()` | `{"type": "string", "format": "date"}`, `"date-time"` |
| `Decimal.t()` | `{"type": "string", "format": "decimal"}` |
| `integer()`, `pos_integer()`, `1..100` | `{"type": "integer"}` with `minimum`/`maximum` |
| `float()`, `number()` | `{"type": "number"}` |
| `boolean()` | `{"type": "boolean"}` |
| `:active \| :inactive` | `{"enum": ["active", "inactive"]}` |
| `integer() \| nil` | `{"type": ["integer", "null"]}` |
| `[t]`, `list(t)`, `nonempty_list(t)` | `{"type": "array", "items": ...}` |
| `%{required(:a) => t, optional(:b) => t}` | `{"type": "object", "required": ["a"], ...}` |
| `%{optional(String.t()) => t}` | `{"type": "object", "additionalProperties": ...}` |
| `%MyStruct{}` and other named types | `{"$ref": "#/components/schemas/..."}` |
Types that cannot be represented, such as `term()`, `atom()`, `map()`,
`pid()` or tuples, are reported as errors. See `Portolan.Type` for the
complete reference.
## Examples
The [`examples`](https://github.com/altenwald/portolan/tree/main/examples)
directory has complete Phoenix applications: one with Phoenix only, one
with Ecto and one with Ecto and Decimal.
## Installation
Add `portolan` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:portolan, "~> 0.1.0"}
]
end
```
`Decimal.t()` parameters require the optional
[`decimal`](https://hex.pm/packages/decimal) dependency, version 3.0 or
later, as earlier ones accept exponents that exhaust the memory
([CVE-2026-32686](https://osv.dev/vulnerability/EEF-CVE-2026-32686)), and
`{:error, Ecto.Changeset.t()}` results the optional
[`ecto`](https://hex.pm/packages/ecto) one.
## License
Portolan is released under the [MIT License](LICENSE).