Hi all,
I’ve been working on PhoenixSpectral, a library that makes Phoenix controller @spec annotations drive both OpenAPI generation and runtime request/response validation — no separate schema definitions.
The main design departure from standard Phoenix is the action signature. Instead of action(conn, params), controllers receive five explicit arguments — (conn, path_args, query_params, headers, body):
@spec update(Plug.Conn.t(), %{id: integer()}, %{notify: boolean()}, %{"x-api-key": String.t()}, User.t()) ::
{200, %{}, User.t()} | {404, %{}, Error.t()}
def update(_conn, %{id: id}, %{notify: notify}, %{"x-api-key": _key}, body) do
case MyApp.Users.update(id, body, notify: notify) do
{:ok, user} -> {200, %{}, user}
:not_found -> {404, %{}, %Error{message: "User not found"}}
end
end
By the time update/5 is called, the incoming HTTP request has already been validated and decoded against the typespec: id is an integer() (not a string), body is a fully populated %User{} struct, etc. Invalid requests are rejected with a 400 before reaching the function. Each source is kept separate because the body is a typed struct (which can’t be merged into a flat map), and the OpenAPI generator needs to know whether a field comes from path, query, header, or body to emit a correct spec. Actions return a {status, headers, body} 3-tuple (union return types produce multiple OpenAPI response entries automatically) or a Plug.Conn directly for streaming and file responses. The repo also contains an example app.
Before stabilising the API I’d love to hear your feedback. Eg, Does five separate arguments feel ergonomic? Does {status, headers, body} as a return type feel right?
Thanks for any thoughts!






















