PhoenixKit.API.Problem (phoenix_kit v2.64.1)

Copy Markdown View Source

RFC 9457 application/problem+json errors for the machine API.

Every error the API sends, from the endpoint's PhoenixKit.API.BodyGuard to a tool handler's {:error, code, message, context}, goes through send/4, so a client sees one shape:

{"type": "https://phoenixkit.dev/api/errors/forbidden",
 "title": "Forbidden", "status": 403, "code": "forbidden",
 "detail": "This key lacks catalogue.prices:write.",
 "context": {"required_scope": "catalogue.prices:write"},
 "request_id": "F9x..."}

code is the contract: stable, machine-readable, and what an agent branches on. detail is always English prose for a human or an agent to relay. context carries the fields that make the error actionable (required_scope, errors, retry_after, ...).

The codes of core are a closed table here (core_code?/1). A module adds its own through PhoenixKit.Module.api_problems/0 (it can never replace a code of core), and PhoenixKit.API.Legacy supplies the few codes the API had before they became a module's, for as long as no enabled module declares them. Both are read only here, when an error is answered, so a successful call pays nothing. statuses/0 is the table as it stands right now. A code that is in none of the three answers 500 and is logged: it means a handler returned a code nothing declares.

Summary

Functions

The problem document as a map with string keys.

Whether code is one of core's own, which no module can override.

What is declared for a code that core does not define: %{status, title, advice} from the enabled module that declares it, else from PhoenixKit.API.Legacy, else nil. A module's declaration replaces the legacy one as a whole.

Sends the problem document for code and halts the connection.

The HTTP status of an error code.

Every error code the API can answer with right now, with its HTTP status: core's, those of the enabled modules and the PhoenixKit.API.Legacy ones no module declares.

Functions

body(code, detail, context, request_id)

@spec body(atom(), String.t(), map(), String.t() | nil) :: map()

The problem document as a map with string keys.

request_id is left out of the map when it is nil.

core_code?(code)

@spec core_code?(atom()) :: boolean()

Whether code is one of core's own, which no module can override.

declared(code)

@spec declared(atom()) ::
  %{status: pos_integer(), title: String.t(), advice: String.t() | nil} | nil

What is declared for a code that core does not define: %{status, title, advice} from the enabled module that declares it, else from PhoenixKit.API.Legacy, else nil. A module's declaration replaces the legacy one as a whole.

send(conn, code, detail, context \\ %{})

@spec send(Plug.Conn.t(), atom(), String.t(), map()) :: Plug.Conn.t()

Sends the problem document for code and halts the connection.

The response carries content-type: application/problem+json and cache-control: no-store. Three codes add a header: unauthorized a www-authenticate challenge, forbidden with a required_scope in the context an insufficient_scope challenge, and any context with a retry_after (seconds) a retry-after header.

The request id is conn.assigns.phoenix_kit_api_request_id when the PhoenixKit.API.Auth plug has run, else the x-request-id response header that Plug.RequestId set. A header value with characters outside [A-Za-z0-9_=+/-] (Plug.RequestId accepts any client-sent id of 20..200 bytes) is replaced by a fresh id, in the header and in the body, the same rule Auth applies.

status(code)

@spec status(atom()) :: pos_integer()

The HTTP status of an error code.

A code that no table declares answers 500.

statuses()

@spec statuses() :: %{required(atom()) => pos_integer()}

Every error code the API can answer with right now, with its HTTP status: core's, those of the enabled modules and the PhoenixKit.API.Legacy ones no module declares.