| name | phoenix-json-api |
| type | atomic |
| tags | ["atomic"] |
| license | MIT |
| description | Handles Phoenix-specific JSON API construction end-to-end. Use when building or modifying Phoenix API controllers, router pipelines, FallbackController error handling, paginated list endpoints, URL-versioned API routes (/api/v1/), or Bearer token authentication plugs in an Elixir/Phoenix application. Covers the full workflow from route definition to structured JSON error responses. Trigger words: Phoenix JSON API, API pipeline, FallbackController, paginated API, Bearer token plug,
API versioning, Elixir API controller, action_fallback.
|
| metadata | {"version":"1.0.0","user-invocable":"true"} |
Phoenix JSON API
Canonical FP bar: docs/fcis-engineering-rules.md — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Keep LiveView/controller callbacks thin; delegate business rules to contexts/pure modules.
RULES — Follow these with no exceptions
1. Use the :api pipeline — don't mix HTML and JSON pipelines; API routes skip CSRF and sessions
2. Render errors as structured JSON — {:error, changeset} must become {"errors": {...}}
3. Version APIs via URL prefix (/api/v1/) — not headers; URL versioning is visible and cacheable
4. Use FallbackController for consistent error handling — every action returns {:ok, result} or {:error, reason}
Build Workflow
Follow these steps in order when constructing a new API endpoint:
- Define the route in the
:api (or :api_auth) pipeline scope with a versioned URL prefix
- Create the controller with
action_fallback MyAppWeb.FallbackController and return {:ok, _} / {:error, _} from every action
- Verify error responses — confirm that invalid input and missing resources return structured JSON (e.g.,
{"errors": {...}}) before proceeding
- Add the auth plug (
ApiAuth) to protected scopes; confirm that missing/invalid tokens yield 401 with a JSON body
- Paginate list endpoints — ensure
index accepts page/per_page params and never returns an unbounded collection
API Pipeline Setup
❌ Bad — one mixed pipeline (drags CSRF/session into JSON) and header-based versioning:
scope "/api", MyAppWeb do
pipe_through :browser # CSRF + session cookies on a JSON API
# version selected from a header — invisible and hard to cache
resources "/posts", PostController
end
✅ Good — a dedicated :api pipeline with a versioned URL prefix:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :api do
plug :accepts, ["json"]
end
pipeline :api_auth do
plug MyAppWeb.Plugs.ApiAuth
end
# Public endpoints
scope "/api/v1", MyAppWeb.API.V1, as: :api_v1 do
pipe_through :api
post "/auth/login", AuthController, :login
post "/auth/register", AuthController, :register
end
# Protected endpoints
scope "/api/v1", MyAppWeb.API.V1, as: :api_v1 do
pipe_through [:api, :api_auth]
resources "/posts", PostController, except: [:new, :edit]
end
end
Controller Pattern
❌ Bad — no action_fallback, error handling duplicated inline, unstructured error body:
def create(conn, %{"post" => post_params}) do
case Blog.create_post(post_params) do
{:ok, post} ->
json(conn, %{data: post})
{:error, _changeset} ->
# plain string, no status control, inconsistent shape
json(conn, %{error: "could not create"})
end
end
✅ Good — action_fallback handles every {:error, _}; actions stay focused on the happy path:
defmodule MyAppWeb.API.V1.PostController do
use MyAppWeb, :controller
alias MyApp.Blog
alias MyApp.Blog.Post
action_fallback MyAppWeb.FallbackController
def index(conn, params) do
page = parse_int(params["page"], 1)
per_page = parse_int(params["per_page"], 20) |> min(100)
{posts, total} = Blog.list_posts(page: page, per_page: per_page)
conn
|> put_resp_header("x-total-count", to_string(total))
|> json(%{
data: Enum.map(posts, &post_json/1),
meta: %{page: page, per_page: per_page, total: total}
})
end
def create(conn, %{"post" => post_params}) do
with {:ok, %Post{} = post} <- Blog.create_post(post_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/v1/posts/#{post}")
|> json(%{data: post_json(post)})
end
end
defp post_json(post) do
%{
id: post.id,
title: post.title,
body: post.body,
inserted_at: post.inserted_at
}
end
defp parse_int(nil, default), do: default
defp parse_int(value, default) when is_binary(value) do
case value |> String.trim() |> Integer.parse() do
{n, ""} -> max(n, 1)
_ -> default
end
end
defp parse_int(value, default) when is_integer(value), do: max(value, 1)
defp parse_int(_, default), do: default
end
FallbackController
defmodule MyAppWeb.FallbackController do
use MyAppWeb, :controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> json(%{errors: %{detail: "Resource not found"}})
end
def call(conn, {:error, :unauthorized}) do
conn
|> put_status(:unauthorized)
|> json(%{errors: %{detail: "Not authorized"}})
end
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_changeset_errors(changeset)})
end
defp format_changeset_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
end
Bearer Token Authentication
defmodule MyAppWeb.Plugs.ApiAuth do
import Plug.Conn
import Phoenix.Controller
def init(opts), do: opts
def call(conn, _opts) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, user} <- MyApp.Accounts.get_user_by_api_token(token) do
assign(conn, :current_user, user)
else
_ ->
conn
|> put_status(:unauthorized)
|> json(%{errors: %{detail: "Invalid or missing token"}})
|> halt()
end
end
end
Common Pitfalls
| ❌ Don't | ✅ Do |
|---|
Route JSON through the :browser pipeline | Use a dedicated :api pipeline (plug :accepts, ["json"]) |
| Version via a custom header | Version via URL prefix (/api/v1/) — visible and cacheable |
Handle {:error, _} inline in every action | Set action_fallback and return {:ok, _}/{:error, _} |
Return changeset or a bare string on error | Render structured JSON (%{errors: %{...}}) with a proper status |
Return an unbounded index collection | Paginate with page/per_page, cap per_page |
| Trust or echo a missing/invalid token | Reject in the auth plug with 401 + JSON body and halt() |
Integration
| Predecessor | This Skill | Successor |
|---|
| elixir-essentials | phoenix-json-api | testing-essentials |
| security-essentials | phoenix-json-api | req-http-client |