--- 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). ```elixir 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` (not `YourView`) - 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/1` helpers for reuse ## Step 2: Create the Controller Create `lib/petal_pro_api/controllers/your_controller.ex`: ```elixir 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` (NOT `use PetalProApi`) - `use OpenApiSpex.ControllerSpecs` enables the `operation`, `tags`, and `security` macros - `action_fallback PetalProWeb.FallbackController` handles `{:error, changeset}`, `{:error, :unauthorized}`, `{:error, :forbidden}`, and `{:error, :not_found}` tuples automatically - `tags` groups endpoints in Swagger UI - `security` at the module level applies to ALL actions; omit it for public endpoints - Each `operation` block sits directly above its corresponding function - The `"authorization"` string references the bearer scheme defined in `api_spec.ex` ## Step 3: Add OpenAPI Schemas Add schema modules inside `lib/petal_pro_api/schemas.ex`, nested within the existing `PetalProApi.Schemas` module: ```elixir 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` (nested `defmodule`) - Always include `@moduledoc false` - Use `OpenApiSpex.schema(%{...})` (NOT `%OpenApiSpex.Schema{}` struct directly) - Always provide `title`, `description`, `type`, `properties`, and `example` - Use `required: [:field]` (list syntax) NOT `require: {:field}` (the latter is a bug in the existing code) - Reference other schemas by module: `items: PetalProApi.Schemas.YourItem` - For arrays, use `type: :array` with `items:` pointing to the item schema - String IDs use `format: :uuid`; emails use `format: :email`; passwords use `format: :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: ```elixir 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: ```elixir 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 `PetalProApi` as the module alias, so use bare controller names - Use RESTful verbs: `get`, `post`, `patch`, `put`, `delete` - Use kebab-case for multi-word paths: `/your-items` not `/your_items` ## Step 5: Write Tests Create `test/petal_pro_api/your_controller_test.exs`: ```elixir 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` (NOT `PetalProApi.ConnCase`) - Set `"accept"` header to `"application/json"` in setup - `put_bearer_token(conn, user)` is defined in `test/support/conn_case.ex` — it creates an API token and sets the `Authorization: Bearer ` 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.ex` with `use PetalProWeb, :controller` - [ ] JSON view at `lib/petal_pro_api/controllers/your_json.ex` with matching action names - [ ] Schemas added inside `PetalProApi.Schemas` in `lib/petal_pro_api/schemas.ex` - [ ] `operation` spec 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.exs` with auth and unauth cases - [ ] Run `mix phx.routes | grep api` to verify routes resolve - [ ] Run `mix test test/petal_pro_api/your_controller_test.exs` to verify tests pass - [ ] Visit `http://localhost:4000/dev/swaggerui` to confirm endpoint appears in Swagger UI - [ ] Run `mix openapi.spec.json --spec PetalProApi.ApiSpec` to validate the OpenAPI spec compiles