Portolan
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 document of a Phoenix application from what the code already says:
- the router gives the paths, verbs and actions
@specgives the parameters and responses of every action@typegives the schemas@moduledoc,@docand@typedocgive the descriptions@deprecatedmarks 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
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 goes through every step in a Phoenix 1.7 or 1.8 application, and the embedding guide shows the documentation inside a page of your application. In short:
Add the Portolan compiler after the default ones in mix.exs:
def project do
[
compilers: Mix.compilers() ++ [:portolan],
# ...
]
end
Tell it which router describes the API in config/config.exs:
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:
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:
plug Plug.Static, at: "/", from: :my_app, only: ~w(assets openapi.json openapi.html)
The interface is Scalar by default, and it can be
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 @types 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:
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
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:
def deps do
[
{:portolan, "~> 0.1.0"}
]
end
Decimal.t() parameters require the optional
decimal dependency, version 3.0 or
later, as earlier ones accept exponents that exhaust the memory
(CVE-2026-32686), and
{:error, Ecto.Changeset.t()} results the optional
ecto one.
License
Portolan is released under the MIT License.