A Phoenix controller module that validates requests and responses using typespecs.
When you use PhoenixSpectral.Controller, your controller actions use the convention
(conn, path_args, query_params, headers, body) instead of the standard
Phoenix (conn, params). Request data is decoded and validated against your typespecs,
and responses are encoded automatically.
Usage
defmodule MyAppWeb.UserController do
use PhoenixSpectral.Controller
@spec show(Plug.Conn.t(), %{id: String.t()}, %{}, %{}, nil) :: {200, %{}, User.t()}
def show(_conn, path_args, _query_params, _headers, _body) do
user = Repo.get!(User, path_args.id)
{200, %{}, user}
end
@spec create(Plug.Conn.t(), %{}, %{}, %{}, UserInput.t()) :: {201, %{}, User.t()} | {422, %{}, Error.t()}
def create(_conn, _path_args, _query_params, _headers, body) do
case Repo.insert(body) do
{:ok, user} -> {201, %{}, user}
{:error, changeset} -> {422, %{}, format_errors(changeset)}
end
end
endOptions
:on_invalid_request— a 2-arity function or{module, function}tuple invoked when request decoding/validation fails. It receives(conn, [%Spectral.Error{}])and must return aPlug.Conn(typically viaPlug.Conn.send_resp/3). Use this to map validation failures into your application's own error envelope and status code. When omitted, the default renderer (below) is used.
All other options are forwarded to use Phoenix.Controller (e.g. formats: [:json]).
Invalid request responses
By default, a failed request validation returns 400 Bad Request with a JSON body:
{
"error": "Bad Request",
"details": [
{
"type": "no_match",
"location": ["currency"],
"message": "no_match at currency",
"got": "USD"
}
]
}Each detail carries the error type, the field location (as strings), a
human-readable message, and got — the offending value from the request — when
one is available. To produce a different shape or status, pass :on_invalid_request.
Required vs optional query params and headers
%{required(:key) => type}— missing key returns400 Bad Request%{optional(:key) => type}— missing key is omitted from the decoded map
Path parameters are always required.
OpenAPI annotations with spectral/1
use PhoenixSpectral.Controller implies use Spectral. Use spectral/1 before
@spec to annotate the endpoint with summary: and description:. On type
aliases, spectral description: "..." adds a description to the parameter in
the OpenAPI output.
Actions without @spec
Actions without a @spec crash on dispatch and during OpenAPI generation — use
a plain use Phoenix.Controller module to bypass PhoenixSpectral entirely.
Using conn
conn is always passed as the first argument. Use it for out-of-band context —
conn.assigns (auth from upstream plugs), conn.remote_ip, conn.host,
conn.method, conn.private, etc.
Do not use conn to access data that is already decoded and validated by the
framework: use path_args, query_params, headers, and body instead.
Reading from conn.path_params, conn.query_params, conn.req_headers, or
conn.body_params directly bypasses type validation.
An action may also return conn directly for streaming, file sends, or any
other response that cannot be expressed as {status, headers, body}. In that case,
PhoenixSpectral passes the conn through without schema validation — the typespec still
documents the endpoint, but the response is the caller's responsibility.
How It Works
- Extracts path params, query params, headers, and body from
conn - Decodes and validates them against the action's typespec via
Spectral.decode - Calls your handler as
action(conn, path_args, query_params, headers, decoded_body) - Encodes the
{status, headers, body}response viaSpectral.encode - Sends the response on
conn - On validation failure, returns a 400 response