name: add-api-endpoint description: Adds a new REST API endpoint to the Petal Pro API layer with OpenAPI specs, bearer auth, JSON views, and tests. Use when creating any new /api route — covers controller, JSON view, schemas, OpenAPI operation specs, routing, and testing. Triggers on "add API endpoint", "new API route", "create API controller". argument-hint: "[resource and actions, e.g. 'products CRUD' or 'GET /api/stats']"
Add API Endpoint
Add a new REST API endpoint to the Petal Pro API layer. The argument should describe the resource and actions.
Endpoint to add: $ARGUMENTS
Architecture Overview
The API layer lives in lib/petal_pro_api/ (NOT lib/petal_pro_web/):
lib/petal_pro_api/
api_spec.ex # OpenAPI spec definition
schemas.ex # All request/response OpenAPI schemas
routes.ex # Route macro (used by router.ex)
controllers/
your_controller.ex # Controller with OpenAPI operation specs
your_json.ex # JSON view (serialization)
Tests go in test/petal_pro_api/your_controller_test.exs.
Routes are defined in lib/petal_pro_api/routes.ex and pulled into the main router via use PetalProApi.Routes in lib/petal_pro_web/router.ex.
Step 1: Create the JSON View
Create lib/petal_pro_api/controllers/your_json.ex. This module serializes data into JSON response maps. Do NOT use Phoenix.View (it's removed from Phoenix).
defmodule PetalProApi.YourJSON do
@doc """
Renders a list of items.
"""
def index(%{items: items}) do
%{data: Enum.map(items, &data/1)}
end
@doc """
Renders a single item.
"""
def show(%{item: item}) do
%{data: data(item)}
end
defp data(item) do
%{
id: item.id,
name: item.name,
inserted_at: item.inserted_at
}
end
end
Key points:
- Module name:
PetalProApi.YourJSON(notYourView) - Public functions match the controller action name (
:show,:index, etc.) - The assigns map from
render(conn, :show, item: item)becomes the function argument - Keep serialization logic in private
data/1helpers for reuse
Step 2: Create the Controller
Create lib/petal_pro_api/controllers/your_controller.ex:
defmodule PetalProApi.YourController do
use PetalProWeb, :controller
use OpenApiSpex.ControllerSpecs
alias OpenApiSpex.Reference
alias PetalPro.YourContext
alias PetalProApi.Schemas
action_fallback PetalProWeb.FallbackController
tags ["your-resource"]
# Add this line ONLY if all actions require auth:
security [%{"authorization" => []}]
operation :index,
summary: "List items",
description: "Returns all items for the authenticated user",
responses: [
ok: {"Items list", "application/json", Schemas.YourItemList},
unauthorized: %Reference{"$ref": "#/components/responses/unauthorised"}
]
def index(conn, _params) do
user = conn.assigns.current_user
items = YourContext.list_items(user)
render(conn, :index, items: items)
end
operation :show,
summary: "Show item",
description: "Returns a single item by ID",
parameters: [
id: [in: :path, name: "id", type: :string]
],
responses: [
ok: {"Item", "application/json", Schemas.YourItem},
unauthorized: %Reference{"$ref": "#/components/responses/unauthorised"},
not_found: %Reference{"$ref": "#/components/responses/unprocessable_entity"}
]
def show(conn, %{"id" => id}) do
item = YourContext.get_item!(id)
render(conn, :show, item: item)
end
operation :create,
summary: "Create item",
description: "Creates a new item",
request_body: {"Item attributes", "application/json", Schemas.CreateYourItem, required: true},
responses: [
created: {"Created item", "application/json", Schemas.YourItem},
unauthorized: %Reference{"$ref": "#/components/responses/unauthorised"},
unprocessable_entity: %Reference{"$ref": "#/components/responses/unprocessable_entity"}
]
def create(conn, params) do
user = conn.assigns.current_user
with {:ok, item} <- YourContext.create_item(user, params) do
conn
|> put_status(:created)
|> render(:show, item: item)
end
end
end
Key patterns:
use PetalProWeb, :controller(NOTuse PetalProApi)use OpenApiSpex.ControllerSpecsenables theoperation,tags, andsecuritymacrosaction_fallback PetalProWeb.FallbackControllerhandles{:error, changeset},{:error, :unauthorized},{:error, :forbidden}, and{:error, :not_found}tuples automaticallytagsgroups endpoints in Swagger UIsecurityat the module level applies to ALL actions; omit it for public endpoints- Each
operationblock sits directly above its corresponding function - The
"authorization"string references the bearer scheme defined inapi_spec.ex
Step 3: Add OpenAPI Schemas
Add schema modules inside lib/petal_pro_api/schemas.ex, nested within the existing PetalProApi.Schemas module:
defmodule PetalProApi.Schemas do
alias OpenApiSpex.Schema
require OpenApiSpex
# ... existing schemas ...
defmodule YourItem do
@moduledoc false
OpenApiSpex.schema(%{
title: "YourItem",
description: "Response body for an item",
type: :object,
properties: %{
id: %Schema{type: :string, description: "ID", format: :uuid},
name: %Schema{type: :string, description: "Name"},
inserted_at: %Schema{type: :string, description: "Created at", format: :"date-time"}
},
required: [:id, :name],
example: %{
"id" => "019516a0-1234-7abc-8000-000000000001",
"name" => "Example item",
"inserted_at" => "2025-01-01T00:00:00Z"
}
})
end
defmodule YourItemList do
@moduledoc false
OpenApiSpex.schema(%{
title: "YourItemList",
description: "List of items",
type: :object,
properties: %{
data: %Schema{
type: :array,
items: PetalProApi.Schemas.YourItem,
description: "List of items"
}
},
example: %{
"data" => [
%{"id" => "019516a0-1234-7abc-8000-000000000001", "name" => "Example"}
]
}
})
end
defmodule CreateYourItem do
@moduledoc false
OpenApiSpex.schema(%{
title: "CreateYourItem",
description: "Request body for creating an item",
type: :object,
properties: %{
name: %Schema{type: :string, description: "Name"}
},
required: [:name],
example: %{
"name" => "New item"
}
})
end
end
Schema rules:
- Every schema module lives inside
PetalProApi.Schemas(nesteddefmodule) - Always include
@moduledoc false - Use
OpenApiSpex.schema(%{...})(NOT%OpenApiSpex.Schema{}struct directly) - Always provide
title,description,type,properties, andexample - Use
required: [:field](list syntax) NOTrequire: {:field}(the latter is a bug in the existing code) - Reference other schemas by module:
items: PetalProApi.Schemas.YourItem - For arrays, use
type: :arraywithitems:pointing to the item schema - String IDs use
format: :uuid; emails useformat: :email; passwords useformat: :password
Step 4: Add Routes
Edit lib/petal_pro_api/routes.ex. Choose the correct scope based on authentication needs:
Authenticated routes (most common)
Add inside the existing authenticated scope:
scope "/api", PetalProApi do
pipe_through [:api, :api_authenticated]
# existing routes ...
get "/items", YourController, :index
get "/items/:id", YourController, :show
post "/items", YourController, :create
patch "/items/:id", YourController, :update
delete "/items/:id", YourController, :delete
end
Public routes (no auth required)
Add inside the public API scope:
scope "/api", PetalProApi do
pipe_through :api
# existing routes like /sign-in, /register ...
get "/public-items", YourController, :index
end
Route conventions:
- All API routes are prefixed with
/api - The scope provides
PetalProApias the module alias, so use bare controller names - Use RESTful verbs:
get,post,patch,put,delete - Use kebab-case for multi-word paths:
/your-itemsnot/your_items
Step 5: Write Tests
Create test/petal_pro_api/your_controller_test.exs:
defmodule PetalProApi.YourControllerTest do
use PetalProWeb.ConnCase
import PetalPro.AccountsFixtures
setup %{conn: conn} do
user = user_fixture()
{:ok,
conn: put_req_header(conn, "accept", "application/json"),
user: user}
end
describe "index" do
test "lists items for authenticated user", %{conn: conn, user: user} do
conn =
conn
|> put_bearer_token(user)
|> get(~p"/api/items")
assert %{"data" => items} = json_response(conn, 200)
assert is_list(items)
end
test "returns 401 without auth token", %{conn: conn} do
conn = get(conn, ~p"/api/items")
assert response(conn, 401)
end
end
describe "create" do
test "creates item with valid params", %{conn: conn, user: user} do
conn =
conn
|> put_bearer_token(user)
|> post(~p"/api/items", %{name: "Test item"})
assert %{"data" => item} = json_response(conn, 201)
assert item["name"] == "Test item"
end
test "returns 422 with invalid params", %{conn: conn, user: user} do
conn =
conn
|> put_bearer_token(user)
|> post(~p"/api/items", %{})
assert json_response(conn, 422)
end
end
end
Test patterns:
use PetalProWeb.ConnCase(NOTPetalProApi.ConnCase)- Set
"accept"header to"application/json"in setup put_bearer_token(conn, user)is defined intest/support/conn_case.ex— it creates an API token and sets theAuthorization: Bearer <token>header- Always test the unauthenticated case (expect 401)
- Use
json_response(conn, status)to assert and decode response body - Use
response(conn, status)when you only care about status code - Use verified routes:
~p"/api/your-path"
Common Mistakes
| Mistake | Fix |
|---|---|
Module under PetalProWeb namespace | Use PetalProApi namespace for all API modules |
Missing use OpenApiSpex.ControllerSpecs | Required for operation, tags, security macros |
Missing action_fallback | Add action_fallback PetalProWeb.FallbackController |
Schema outside PetalProApi.Schemas | Nest all schemas inside the Schemas module in schemas.ex |
Forgetting @moduledoc false on schemas | Every nested schema module needs it |
Using Phoenix.View | Removed from Phoenix; use YourJSON modules instead |
| Operation missing for an action | OpenAPI spec won't include the route; Swagger UI won't show it |
Putting security on a public controller | Only add security [%{"authorization" => []}] if the route requires auth |
| Auth test passes without token | Check if route is in the wrong scope (public instead of authenticated) |
| Route alias doubled | Scope provides PetalProApi; use YourController not PetalProApi.YourController |
| Returning raw data instead of rendering | Use render(conn, :action, assigns) to go through JSON view, or json(conn, data) for simple responses |
Verification Checklist
- Controller at
lib/petal_pro_api/controllers/your_controller.exwithuse PetalProWeb, :controller - JSON view at
lib/petal_pro_api/controllers/your_json.exwith matching action names - Schemas added inside
PetalProApi.Schemasinlib/petal_pro_api/schemas.ex -
operationspec above every controller action - Routes added to correct scope in
lib/petal_pro_api/routes.ex - Tests at
test/petal_pro_api/your_controller_test.exswith auth and unauth cases - Run
mix phx.routes | grep apito verify routes resolve - Run
mix test test/petal_pro_api/your_controller_test.exsto verify tests pass - Visit
http://localhost:4000/dev/swaggeruito confirm endpoint appears in Swagger UI - Run
mix openapi.spec.json --spec PetalProApi.ApiSpecto validate the OpenAPI spec compiles