code-quality
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when building JSON API endpoints — :api pipeline, phx.gen.json-style JSON views, FallbackController, pagination, Bearer tokens, rate limits, CORS. Not for HTML/LiveView controllers.
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill phoenix-json-api --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
/phoenix-json-apiContext preview
The summary Claude sees to decide when to auto-load this skill.
Use when building JSON API endpoints — :api pipeline, phx.gen.json-style JSON views, FallbackController, pagination, Bearer tokens, rate limits, CORS. Not for HTML/LiveView controllers.
name: phoenix-json-api description: "Use when building JSON API endpoints — :api pipeline, phx.gen.json-style JSON views, FallbackController, pagination, Bearer tokens, rate limits, CORS. Not for HTML/LiveView controllers."
> Verified against Elixir 1.20, Phoenix 1.8, Ecto 3.14 — October 2026
1. **Use the `:api` pipeline** — `plug :accepts, ["json"]`, no session, CSRF or browser headers; API clients authenticate with tokens 2. **Pass `conn.assigns.current_scope` as the first argument to every context call** — `Blog.get_post!(scope, id)` loads by id *and* owner; loading or deleting by a client-supplied id alone is an IDOR 3. **Follow `phx.gen.json`** — `action_fallback`, `render(conn, :show, post: post)` with a `PostJSON` module, `ChangesetJSON` for validation errors; `json/2` is fine for one-off bodies 4. **The `FallbackController` handles every error your contexts return, plus a catch-all 500** — an unmatched `{:error, _}` otherwise raises `FunctionClauseError`; never put `inspect(reason)` in the response body 5. **Never return unbounded collections** — parse `page`/`per_page` with `Integer.parse/1` and clamp (`1..100`); order by a unique tiebreaker (`desc: p.id`); use keyset pagination for large or fast-changing tables 6. **Bearer tokens are random, stored hashed, expire, and are revocable** — follow the generated `UserToken` pattern; never store or compare plaintext tokens 7. **Rate-limit login and token endpoints, and keep failures indistinguishable** — same error for unknown email and wrong password; the generated `get_user_by_email_and_password/2` already calls `Bcrypt.no_user_verify/0` for unknown users 8. **Preload every association the JSON view renders** — in the context function, not the view; `post.user` on an unloaded association is `%Ecto.Association.NotLoaded{}` (and the generated `Post` has no `belongs_to :user` until you add one) 9. **Version via URL prefix (`/api/v1`)** — no `as:` option needed; Phoenix 1.8 apps use verified routes, not router helpers
---
# lib/my_app_web/router.ex pipeline :api do plug :accepts, ["json"] end pipeline :api_auth do plug MyAppWeb.Plugs.ApiAuth end scope "/api/v1", MyAppWeb.API.V1 do pipe_through :api post "/tokens", TokenController, :create end scope "/api/v1", MyAppWeb.API.V1 do pipe_through [:api, :api_auth] resources "/posts", PostController, except: [:new, :edit] end
When v2 is needed, add `scope "/api/v2", MyAppWeb.API.V2` and put only the controllers that changed under `controllers/api/v2/`.
---
`phx.gen.json` with scopes generates exactly this shape. `get_post!/2` raises `Ecto.NoResultsError`, which `phoenix_ecto` turns into a 404 rendered by `ErrorJSON` — for another user's post as well as a missing one. Generated scoped controllers and their tests assume a pipeline that assigns `:current_scope`; see the `phoenix-scopes-and-generators` skill.
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_pos_int(params["page"], 1)
per_page = params["per_page"] |> parse_pos_int(20) |> min(100)
{posts, total} = Blog.paginate_posts(conn.assigns.current_scope, page: page, per_page: per_page)
render(conn, :index, posts: posts, meta: %{page: page, per_page: per_page, total: total})
end
def create(conn, %{"post" => post_params}) do
with {:ok, %Post{} = post} <- Blog.create_post(conn.assigns.current_scope, post_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/v1/posts/#{post}")
|> render(:show, post: post)
end
end
def show(conn, %{"id" => id}) do
post = Blog.get_post!(conn.assigns.current_scope, id)
render(conn, :show, post: post)
end
def update(conn, %{"id" => id, "post" => post_params}) do
scope = conn.assigns.current_scope
post = Blog.get_post!(scope, id)
with {:ok, %Post{} = post} <- Blog.update_post(scope, post, post_params) do
render(conn, :show, post: post)
end
end
def delete(conn, %{"id" => id}) do
scope = conn.assigns.current_scope
post = Blog.get_post!(scope, id)
with {:ok, %Post{}} <- Blog.delete_post(scope, post) do
send_resp(conn, :no_content, "")
end
end
# "abc", "0", "-5" and per_page[]=1 all fall back to the default instead of a 500
defp parse_pos_int(value, default) when is_binary(value) do
case Integer.parse(value) do
{n, ""} when n > 0 -> n
_ -> default
end
end
defp parse_pos_int(_value, default), do: default
end<!-- epg:bad -->
# IDOR: any authenticated user can read or delete any post by guessing ids
def delete(conn, %{"id" => id}) do
post = Repo.get!(Post, id)
{:ok, _} = Repo.delete(post)
send_resp(conn, :no_content, "")
end
# 500 on "abc"; 0 or a negative value reaches LIMIT and Postgres errors
per_page = String.to_integer(params["per_page"])---
# lib/my_app_web/controllers/api/v1/post_json.ex
defmodule MyAppWeb.API.V1.PostJSON do
alias MyApp.Blog.Post
def index(%{posts: posts, meta: meta}) do
%{data: for(post <- posts, do: data(post)), meta: meta}
end
def show(%{post: post}) do
%{data: data(post)}
end
defp data(%Post{} = post) do
%{
id: post.id,
title: post.title,
body: post.body,
user_id: post.user_id,
inserted_at: post.inserted_at
}
end
endTo render an association (say the author of a post in a shared feed), the schema needs it: the generated `Post` has `field :user_id, :id`, so replace that with `belongs_to :user, MyApp.Accounts.User` (the changeset's `put_change(:user_id, scope.user.id)` keeps working). Then preload `:user` in **every** context function whose result reaches the view (`get_post!/2`
· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when preparing releases or deployment config — mix phx.gen.release, runtime.exs vs compile-time config, release migrations,…
Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date…
Use when defining schemas, writing queries, or creating migrations — scoped context functions, Repo.transact, upserts, indexes,…
Use when migrating PostgreSQL tables that serve live traffic — index concurrently,…
Use when one form or operation manages parent and child records together — cast_assoc/cast_embed, inputs_for with sort/drop…