Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: phoenix-json-api description: MANDATORY for ALL JSON API work. Invoke before writing API controllers, pipelines, or JSON responses. file_patterns:
- "**/*_controller.ex"
- "**/*_json.ex"
- "**/router.ex"
auto_suggest: true
Phoenix JSON API
RULES — Follow these with no exceptions
- Use the `:api` pipeline — don't mix HTML and JSON pipelines; API routes skip CSRF, sessions, and browser headers
- Render errors as structured JSON —
{:error, changeset}must become{"errors": {...}}; never return raw text or HTML errors - Use offset/limit for pagination — never return unbounded collections; default to a sensible limit (e.g., 20)
- Version APIs via URL prefix (`/api/v1/`) — not headers; URL versioning is visible, cacheable, and debuggable
- Use `FallbackController` for consistent error handling — every action returns
{:ok, result}or{:error, reason}; the fallback renders errors - Authenticate via Bearer tokens in `Authorization` header — not cookies; API clients don't have browser sessions
- Use `json/2` helper — ensures
Content-Type: application/json; avoidrenderfor simple JSON responses
API Pipeline Setup
# lib/my_app_web/router.exdefmodule MyAppWeb.Router douse MyAppWeb, :routerpipeline :api doplug :accepts, ["json"]# No :fetch_session, :protect_from_forgery, :put_secure_browser_headers# APIs use tokens, not sessionsendpipeline :api_auth doplug MyAppWeb.Plugs.ApiAuthend# Public endpoints (no auth required)scope "/api/v1", MyAppWeb.API.V1, as: :api_v1 dopipe_through :apipost "/auth/login", AuthController, :loginpost "/auth/register", AuthController, :registerend# Protected endpointsscope "/api/v1", MyAppWeb.API.V1, as: :api_v1 dopipe_through [:api, :api_auth]resources "/posts", PostController, except: [:new, :edit]resources "/users", UserController, only: [:index, :show, :update]endend
Controller Pattern
Controllers return {:ok, result} or {:error, reason} — the FallbackController handles error rendering.
defmodule MyAppWeb.API.V1.PostController douse MyAppWeb, :controlleralias MyApp.Blogalias MyApp.Blog.Postaction_fallback MyAppWeb.FallbackControllerdef index(conn, params) dopage = Map.get(params, "page", "1") |> String.to_integer()per_page = Map.get(params, "per_page", "20") |> String.to_integer() |> 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}})enddef show(conn, %{"id" => id}) dowith {:ok, post} <- Blog.get_post(id) dojson(conn, %{data: post_json(post)})endenddef create(conn, %{"post" => post_params}) dowith {:ok, %Post{} = post} <- Blog.create_post(post_params) doconn|> put_status(:created)|> put_resp_header("location", ~p"/api/v1/posts/#{post}")|> json(%{data: post_json(post)})endenddef update(conn, %{"id" => id, "post" => post_params}) dowith {:ok, post} <- Blog.get_post(id),{:ok, %Post{} = updated} <- Blog.update_post(post, post_params) dojson(conn, %{data: post_json(updated)})endenddef delete(conn, %{"id" => id}) dowith {:ok, post} <- Blog.get_post(id),{:ok, _} <- Blog.delete_post(post) dosend_resp(conn, :no_content, "")endenddefp post_json(%Post{} = post) do%{id: post.id,title: post.title,body: post.body,inserted_at: post.inserted_at,updated_at: post.updated_at}endend
Bad:
# Mixing concerns — error handling inline, inconsistent responsesdef show(conn, %{"id" => id}) docase Repo.get(Post, id) donil -> conn |> put_status(404) |> text("Not found")post -> conn |> put_status(200) |> render("show.json", post: post)endend
FallbackController
Centralized error handling — every error gets a consistent JSON response.
defmodule MyAppWeb.FallbackController douse MyAppWeb, :controller# Ecto changeset errorsdef call(conn, {:error, %Ecto.Changeset{} = changeset}) doconn|> put_status(:unprocessable_entity)|> json(%{errors: format_changeset_errors(changeset)})end# Not founddef call(conn, {:error, :not_found}) doconn|> put_status(:not_found)|> json(%{errors: %{detail: "Not found"}})end# Unauthorizeddef call(conn, {:error, :unauthorized}) doconn|> put_status(:forbidden)|> json(%{errors: %{detail: "Forbidden"}})end# Generic errordef call(conn, {:error, reason}) when is_binary(reason) doconn|> put_status(:bad_request)|> json(%{errors: %{detail: reason}})enddefp format_changeset_errors(changeset) doEcto.Changeset.traverse_errors(changeset, fn {msg, opts} ->Regex.replace(~r"%{(\w+)}", msg, fn _, key ->opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()end)end)endend
Context functions should return tagged tuples:
defmodule MyApp.Blog dodef get_post(id) docase Repo.get(Post, id) donil -> {:error, :not_found}post -> {:ok, post}endendend
Bearer Token Authentication
defmodule MyAppWeb.Plugs.ApiAuth doimport Plug.Conndef init(opts), do: optsdef call(conn, _opts) dowith ["Bearer " <> token] <- get_req_header(conn, "authorization"),{:ok, user} <- MyApp.Accounts.verify_api_token(token) doassign(conn, :current_user, user)else_ ->conn|> put_status(:unauthorized)|> Phoenix.Controller.json(%{errors: %{detail: "Unauthorized"}})|> halt()endendend
Token generation in the auth controller:
defmodule MyAppWeb.API.V1.AuthController douse MyAppWeb, :controlleralias MyApp.Accountsdef login(conn, %{"email" => email, "password" => password}) docase Accounts.authenticate_user(email, password) do{:ok, user} ->token = Accounts.generate_api_token(user)json(conn, %{data: %{token: token, user_id: user.id}}){:error, :invalid_credentials} ->conn|> put_status(:unauthorized)|> json(%{errors: %{detail: "Invalid email or password"}})endendend
Pagination
Never return unbounded collections. Cap per_page to prevent abuse.
defmodule MyApp.Blog doimport Ecto.Querydef list_posts(opts \\ []) dopage = Keyword.get(opts, :page, 1)per_page = Keyword.get(opts, :per_page, 20) |> min(100)offset = (page - 1) * per_pageposts =from(p in Post,order_by: [desc: p.inserted_at],limit: ^per_page,offset: ^offset)|> Repo.all()total = Repo.aggregate(Post, :count){posts, total}endend
Response format:
{"data": [...],"meta": {"page": 1,"per_page": 20,"total": 142}}
API Versioning
Version via URL prefix. It's visible in logs, cacheable by CDNs, and simple to implement.
# router.exscope "/api/v1", MyAppWeb.API.V1, as: :api_v1 dopipe_through [:api, :api_auth]resources "/posts", PostController, except: [:new, :edit]end# When v2 is needed, add a new scopescope "/api/v2", MyAppWeb.API.V2, as: :api_v2 dopipe_through [:api, :api_auth]resources "/posts", PostController, except: [:new, :edit]end
Controller directory structure:
lib/my_app_web/controllers/api/├── v1/│ ├── post_controller.ex│ ├── user_controller.ex│ └── auth_controller.ex└── v2/└── post_controller.ex # Only modules that changed
JSON Rendering
For simple responses, use json/2. For complex or reusable serialization, use JSON views.
Simple (json/2)
# Direct — good for simple responsesjson(conn, %{data: %{id: post.id, title: post.title}})
JSON Views (for complex/reusable serialization)
# lib/my_app_web/controllers/api/v1/post_json.exdefmodule MyAppWeb.API.V1.PostJSON doalias MyApp.Blog.Postdef index(%{posts: posts, meta: meta}) do%{data: for(post <- posts, do: data(post)), meta: meta}enddef show(%{post: post}) do%{data: data(post)}enddef data(%Post{} = post) do%{id: post.id,title: post.title,body: post.body,author: author_data(post.author),inserted_at: post.inserted_at,updated_at: post.updated_at}enddefp author_data(nil), do: nildefp author_data(author) do%{id: author.id, name: author.name}endend# In controller — use render with the JSON viewdef show(conn, %{"id" => id}) dowith {:ok, post} <- Blog.get_post(id) dorender(conn, :show, post: post)endend
Testing API Endpoints
defmodule MyAppWeb.API.V1.PostControllerTest douse MyAppWeb.ConnCasesetup %{conn: conn} douser = user_fixture()token = MyApp.Accounts.generate_api_token(user)conn =conn|> put_req_header("accept", "application/json")|> put_req_header("authorization", "Bearer #{token}")%{conn: conn, user: user}enddescribe "GET /api/v1/posts" dotest "lists posts with pagination", %{conn: conn} dofor _ <- 1..25, do: post_fixture()conn = get(conn, ~p"/api/v1/posts?page=1&per_page=10")response = json_response(conn, 200)assert length(response["data"]) == 10assert response["meta"]["total"] == 25assert response["meta"]["page"] == 1endenddescribe "POST /api/v1/posts" dotest "creates post with valid data", %{conn: conn} doattrs = %{"post" => %{"title" => "Test", "body" => "Content"}}conn = post(conn, ~p"/api/v1/posts", attrs)assert %{"data" => %{"id" => id, "title" => "Test"}} = json_response(conn, 201)assert get_resp_header(conn, "location") == ["/api/v1/posts/#{id}"]endtest "returns errors with invalid data", %{conn: conn} doattrs = %{"post" => %{"title" => ""}}conn = post(conn, ~p"/api/v1/posts", attrs)assert %{"errors" => errors} = json_response(conn, 422)assert errors["title"] != nilendenddescribe "unauthenticated requests" dotest "returns 401 without token" doconn = build_conn()conn = get(conn, ~p"/api/v1/posts")assert json_response(conn, 401)["errors"]["detail"] == "Unauthorized"endendend
See ecto-essentials skill for query and changeset patterns. See security-essentials skill for token handling and auth security. See testing-essentials skill for comprehensive testing patterns.