igmarin/elixir-phoenix-skills

phoenix-json-api

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 versionin…

First seen Jun 20, 2026

Installation

$ npx skills add igmarin/elixir-phoenix-skills --skill phoenix-json-api

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from igmarin/elixir-phoenix-skills · top by installs.

npx skills add igmarin/elixir-phoenix-skills

Browse all from igmarin/elixir-phoenix-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
version
1.0.0
user-invocable
true

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 7,334 B
  • docs SUMMARY.md 565 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 20 installs

SKILL.md

Phoenix JSON API

Canonical FP bar: [docs/fcis-engineering-rules.md](../../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:

  1. Define the route in the :api (or :api_auth) pipeline scope with a versioned URL prefix
  2. Create the controller with actionfallback MyAppWeb.FallbackController and return {:ok, } / {:error, _} from every action
  3. Verify error responses — confirm that invalid input and missing resources return structured JSON (e.g., {"errors": {...}}) before proceeding
  4. Add the auth plug (ApiAuth) to protected scopes; confirm that missing/invalid tokens yield 401 with a JSON body
  5. 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 — actionfallback 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 actionfallback 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/perpage, cap perpage
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