phoenix-json-api
Skill4 starsChanged 4 months agoMITView on GitHub
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,
In this repository34 other files it gives its agents.
- AGENTS.mdAGENTS.md
- apply-phoenix-controller-conventionsSkill
- ash-frameworkSkill
- benchee-profilingSkill
- broadway-data-pipelinesSkill
- cachex-cachingSkill
- code-qualitySkill
- code-reviewSkill
- deployment-gotchasSkill
- ecto-changeset-patternsSkill
- ecto-essentialsSkill
- ecto-migrationSkill
- ecto-nested-associationsSkill
- elixir-essentialsSkill
- gettext-i18nSkill
- liveview-streamsSkill
- mix-tasks-generatorsSkill
- oban-essentialsSkill
- otp-essentialsSkill
- phoenix-authenticationSkill
- phoenix-authorization-patternsSkill
- phoenix-channels-essentialsSkill
- phoenix-liveview-essentialsSkill
- phoenix-pubsub-patternsSkill
- phoenix-uploadsSkill
- property-based-testingSkill
- refactor-codeSkill
- req-http-clientSkill
- respond-to-reviewSkill
- security-essentialsSkill
- swoosh-emailsSkill
- telemetry-essentialsSkill
- testing-essentialsSkill
- typespec-dialyzerSkill
The file
We split this skill into the rules it gives an agent
1 rule. About 0 are common, 1 is distinctive, and 0 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
0
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 33
Follow these steps in order when constructing a new API endpoint:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Phoenix JSON API
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.FallbackControllerand 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 yield401with a JSON body - Paginate list endpoints — ensure
indexacceptspage/per_pageparams 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
endController 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
endFallbackController
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
endBearer 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
endCommon 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 |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

