Pro Creating Your Own API
Viewing latest docs.
Switch version: v3

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:

  1. Expand a route (e.g. /api/register)
  2. Click Try it out
  3. Fill in parameters and request body
  4. Click Execute

Some routes are protected — calling them without authentication returns 401 Unauthorized.

To authenticate:

  1. Run /api/sign-in with an email and password
  2. Copy the token from the response
  3. Click Authorize at the top of the page
  4. 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:

shell
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:

  1. Build the controller
  2. Add the route
  3. Write a test
  4. Add authentication checks
  5. 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:

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:

routes.ex
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:

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:

shell
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:

elixir
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:

membership_controller.ex
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:

elixir
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:

elixir
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 a securityScheme defined in lib/petal_pro_api/api_spec.ex)
  • operation(:list, ...) describes the list action — its summary, parameters, and possible responses

Now define the response schema. Add to lib/petal_pro_api/schemas.ex:

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.