Creating your own API
Petal Pro ships with a JSON REST API for user management — register, sign in, and update profile. It’s documented with OpenAPI/Swagger out of the box. This guide shows how to extend it with your own endpoints.
For background on the included API, see REST API fundamentals.
Using the Swagger UI
Petal Pro generates an interactive Swagger UI for the API at:
http://localhost:4000/dev/swaggerui
To call an endpoint:
-
Expand a route (e.g.
/api/register) - Click Try it out
- Fill in parameters and request body
- Click Execute
Some routes are protected — calling them without authentication returns 401 Unauthorized.
To authenticate:
-
Run
/api/sign-inwith an email and password -
Copy the
tokenfrom the response - Click Authorize at the top of the page
- Paste the bearer token and click Authorize again
The token is valid for 30 days by default.
Generating the OpenAPI spec
To export the spec as JSON:
mix openapi.spec.json --spec PetalProApi.ApiSpec
You can also save it from the Swagger UI — click the link under the “Petal Pro API” heading and use your browser’s “Save As”.
Extending the API
Let’s add an endpoint that returns the list of organizations a user belongs to. The path forward:
- Build the controller
- Add the route
- Write a test
- Add authentication checks
- Add OpenAPI documentation
If you’d rather just describe what you want and let Claude do the typing, drop this into Claude Code:
Add an API endpoint at GET /api/user/:id/orgs that returns a list of organization
names for the user. Protect it so users can only fetch their own orgs (admins
can fetch any user's orgs). Add tests and OpenAPI docs.
Claude knows the conventions from the CLAUDE.md files. The rest of this guide walks through what gets built.
The controller
Create lib/petal_pro_api/controllers/membership_controller.ex:
defmodule PetalProApi.MembershipController do
use PetalProWeb, :controller
alias PetalPro.Accounts
alias PetalPro.Orgs
action_fallback PetalProWeb.FallbackController
def list(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
org_names = user |> Orgs.list_orgs() |> Enum.map(& &1.name)
json(conn, org_names)
end
end
The route
Add to lib/petal_pro_api/routes.ex inside the existing scope:
scope "/api", PetalProApi do
pipe_through [:api, :api_authenticated]
get "/user/:id", ProfileController, :show
patch "/user/:id/update", ProfileController, :update_profile
post "/user/:id/request-new-email", ProfileController, :request_new_email
post "/user/:id/change-password", ProfileController, :change_password
get "/user/:id/orgs", MembershipController, :list # add this
end
A test
Create test/petal_pro_api/membership_controller_test.exs:
defmodule PetalProApi.MembershipControllerTest do
use PetalProWeb.ConnCase
setup %{conn: conn} do
user = PetalPro.AccountsFixtures.confirmed_user_fixture()
org = PetalPro.OrgsFixtures.org_fixture(user)
membership = PetalPro.Orgs.get_membership!(user, org.slug)
{:ok,
conn: put_req_header(conn, "accept", "application/json"),
user: user,
org: org,
membership: membership}
end
describe "list" do
test "returns the user's organizations", %{conn: conn, user: user} do
conn = get(conn, ~p"/api/user/#{user.id}/orgs")
assert orgs = json_response(conn, 200)
assert Enum.count(orgs) > 0
end
end
end
If you run it now:
mix test test/petal_pro_api/membership_controller_test.exs
It fails:
1) test list returns the user's organizations
** (RuntimeError) expected response with status 200, got: 401
That’s because the route is in the :api_authenticated pipeline and our test hasn’t set up a bearer token.
Authenticating in tests
put_bearer_token/2 is a helper in /test/support/conn_case.ex. It generates a bearer token for a user and adds it to the request as an Authorization header.
Update the test:
test "returns the user's organizations", %{conn: conn, user: user} do
conn =
conn
|> put_bearer_token(user)
|> get(~p"/api/user/#{user.id}/orgs")
assert orgs = json_response(conn, 200)
assert Enum.count(orgs) > 0
end
Now mix test passes.
Authorization (not just authentication)
A bearer token proves who the user is. But you usually also want to check what they’re allowed to do. In our case, users should only be able to fetch their own orgs — unless they’re admins, in which case they can fetch anyone’s.
Petal Pro has a match_current_user plug that does exactly this. It compares the URL’s :id with the authenticated user’s ID. If they match (or the authenticated user is an admin), the plug puts the user into conn.assigns.user. Otherwise it returns 403 Forbidden.
Wire it into the controller:
defmodule PetalProApi.MembershipController do
use PetalProWeb, :controller
alias PetalPro.Orgs
alias PetalProApi.Plugs.MatchCurrentUser
action_fallback PetalProWeb.FallbackController
plug MatchCurrentUser
def list(conn, _params) do
org_names = conn.assigns.user |> Orgs.list_orgs() |> Enum.map(& &1.name)
json(conn, org_names)
end
end
The list function now reads the user from conn.assigns.user instead of looking it up from the URL — by the time the action runs, the plug has already validated and set it.
Add tests for the authorization logic:
setup %{conn: conn} do
user = PetalPro.AccountsFixtures.confirmed_user_fixture()
other_user = PetalPro.AccountsFixtures.confirmed_user_fixture()
admin_user = PetalPro.AccountsFixtures.admin_fixture()
org = PetalPro.OrgsFixtures.org_fixture(user)
{:ok,
conn: put_req_header(conn, "accept", "application/json"),
user: user, other_user: other_user, admin_user: admin_user, org: org}
end
describe "list" do
test "user can fetch their own orgs", %{conn: conn, user: user} do
conn = conn |> put_bearer_token(user) |> get(~p"/api/user/#{user.id}/orgs")
assert json_response(conn, 200)
end
test "user cannot fetch another user's orgs", %{conn: conn, other_user: other, user: user} do
conn = conn |> put_bearer_token(other) |> get(~p"/api/user/#{user.id}/orgs")
assert json_response(conn, 403)
end
test "admin can fetch any user's orgs", %{conn: conn, admin_user: admin, user: user} do
conn = conn |> put_bearer_token(admin) |> get(~p"/api/user/#{user.id}/orgs")
assert json_response(conn, 200)
end
end
All three pass. The endpoint is now correctly authenticated and authorized.
Adding it to the OpenAPI spec
Your endpoint works, but it doesn’t show up in the Swagger UI yet. Petal Pro uses OpenApiSpex — you describe each endpoint with metadata in the controller.
In membership_controller.ex add:
defmodule PetalProApi.MembershipController do
use PetalProWeb, :controller
use OpenApiSpex.ControllerSpecs
alias OpenApiSpex.Schema
alias PetalPro.Orgs
alias PetalProApi.Plugs.MatchCurrentUser
action_fallback PetalProWeb.FallbackController
plug MatchCurrentUser
tags(["Memberships"])
security([%{"authorization" => []}])
operation(:list,
summary: "List user's organizations",
description: "Returns the names of all organizations the user belongs to.",
parameters: [
id: [in: :path, type: :string, description: "User ID", required: true]
],
responses: [
ok: {"List of organization names", "application/json", OrganisationNames},
unauthorized: {"Unauthorized", "application/json", PetalProApi.Schemas.Error},
forbidden: {"Forbidden", "application/json", PetalProApi.Schemas.Error}
]
)
def list(conn, _params) do
org_names = conn.assigns.user |> Orgs.list_orgs() |> Enum.map(& &1.name)
json(conn, org_names)
end
end
A quick walk-through of what each piece does:
-
tags(["Memberships"])groups this controller’s endpoints under “Memberships” in the Swagger UI -
security([%{"authorization" => []}])tells OpenAPI that endpoints here use bearer auth (the"authorization"key references asecuritySchemedefined inlib/petal_pro_api/api_spec.ex) -
operation(:list, ...)describes thelistaction — its summary, parameters, and possible responses
Now define the response schema. Add to lib/petal_pro_api/schemas.ex:
defmodule OrganisationNames do
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
title: "OrganisationNames",
description: "List of organisation names for a user",
type: :array,
items: %Schema{description: "name", type: :string},
example: ["Acme Inc", "Globex Corp"]
})
end
That’s it. Restart the server, refresh /dev/swaggerui, and your endpoint shows up under “Memberships” with full documentation, the padlock indicating it requires auth, and an interactive request form.