Portolan Logo

Portolan

Hex Package Hex Docs License: MIT

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:

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 (or the status of your error renderer) 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, the existing API guide moves an API that already has clients, 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
Security schemes the :security_schemes option
Security of the operations @doc security: ... or the :security option
Error bodies the :error_renderer option
Responses sent by plugs the :responses options
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, {:not_found, msg}} the same, with the message msg
{:ok, Portolan.Text.t()} 200 with plain text, as text/plain
{:error, Ecto.Changeset.t()} 422 with the validation errors, see 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.

Plain text, built with Portolan.Text.new/1, is sent as text/plain. It can share its status with JSON, as {:ok, [Entry.t()]} | {:ok, Portolan.Text.t()}, and both content types are documented.

Fields left out of the JSON of a struct, with @derive {Jason.Encoder, only: [...]} or except: [...], are left out of its schema too.

Errors

Errors follow the format of Phoenix, {"errors": {"detail": "Not Found"}}, and validation errors answer 422. An API with clients that expect another format can keep it with a module implementing Portolan.ErrorRenderer:

config :my_app, Portolan,
  router: MyAppWeb.Router,
  error_renderer: MyAppWeb.ApiErrors

The same module answers the errors and describes them in the document, including the status of validation errors.

Security

Declare the security schemes, and the requirements of the operations:

config :my_app, Portolan,
  router: MyAppWeb.Router,
  security_schemes: %{bearer: %{type: "http", scheme: "bearer"}},
  security: {MyAppWeb.ApiSecurity, :requirements}

:security takes the requirements of every operation, as [bearer: []], or a function called with the controller and the action, so they can follow the pipelines of the router. An action can declare its own with @doc security: [], for a public one, or @doc security: [bearer: ["admin"]]. See Portolan.Security.

Shared responses

Plugs answer before the actions: the authentication pipeline of the router, or a plug of the controller loading the resource of the path. Declare their responses once, for every operation or for the ones of a controller:

config :my_app, Portolan,
  router: MyAppWeb.Router,
  responses: [unauthorized: :text]
use Portolan.Controller, responses: [:forbidden, :not_found]

A status is an error body of the error renderer, {status, :text} plain text and {status, nil} no body. :responses also takes a function called with the controller and the action, as :security. See Portolan.SharedResponses.

Tags

The operations of a controller are grouped by a tag, the name of the module without Controller by default. Name it with use Portolan.Controller, tag: "Users".

Adopting it in an existing API

Actions receive their parameters cast, with atom keys. When the actions, or the contexts they call, expect the parameters as Phoenix gives them, use use Portolan.Controller, cast: false: parameters are still validated, and documented, but arrive with string keys. The existing API guide moves an API with clients, and its own error format, without changing what they receive.

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.2.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.