Tutorial: Building a Pet Shop API

Build a Pet Shop API with controller stubs, Ecto schemas, and a PostgreSQL backend from a Paradox specification. You define your types and URI endpoints in .dox, paradox-phoenix generates the project scaffolding, and you fill in the handler logic.

What You Will Build

A Phoenix application with these API endpoints:

Method Path Description

POST

/api/pets

Create a pet

GET

/api/pets/:integer

Get a pet by ID

GET

/api/pets

List all pets

Prerequisites

  • The paradox CLI on your PATH

  • paradox-phoenix on your PATH

  • Elixir 1.14+ and Erlang/OTP 25+

  • A running PostgreSQL database

paradox-phoenix requires a PostgreSQL connection. By default it connects to postgres://localhost:5432/paradox. Use --database-url to override.

Step 1: Write the Specification

Create petshop.dox:

import std

type Pet
  name: Text
  species: Text
  age: Integer

valid Pet
  name != "" | Name is required
  species != "" | Species is required
  age > 0 | Age must be positive

createPet: URI
  http://localhost/pets POST (Pet. Pet)

getPet: URI
  http://localhost/pets/${Integer} GET (Unit. Pet)

listPets: URI
  http://localhost/pets GET (Unit. [Pet])

Each URI constant defines an API endpoint with a path, HTTP method, and input/output types.

Step 2: Run the Generator

paradox-phoenix --title "PetShop" --uris createPet --uris getPet --uris listPets

Output:

Generating Paradox base output...
Parsing specification...
Resolving URI endpoints...
Found 3 URI endpoints:
  POST /pets (Pet -> Pet)
  GET /pets/${Integer} (Unit -> Pet)
  GET /pets (Unit -> [Pet])

Generating Phoenix project: PetShop

Done! Your Phoenix app is at: .dox/pet_shop/

To get started:
  cd .dox/pet_shop
  mix deps.get
  mix ecto.create
  mix ecto.migrate
  mix phx.server

Step 3: Boot the App

cd .dox/pet_shop
mix deps.get
mix ecto.create
mix ecto.migrate
mix phx.server

The server starts on http://localhost:4000.

Step 4: Fill in the Stubs

Open .dox/pet_shop/lib/pet_shop_web/controllers/pets_controller.ex. You’ll see stub handlers that return 501 Not Implemented. Replace each one with Ecto-backed logic using the generated schema.

First, add the aliases and a shared error helper at the top of the module:

defmodule PetShopWeb.PetsController do
  use PetShopWeb, :controller

  alias PetShop.Repo
  alias PetShop.Schemas.Pet
  import Ecto.Query

create_pet — POST /pets

Insert a new pet via the generated changeset (which includes Dox validation bridging):

  def create_pet(conn, params) do
    changeset = Pet.changeset(%Pet{}, params)

    case Repo.insert(changeset) do
      {:ok, pet} ->
        conn |> put_status(:created) |> json(Pet.to_json(pet))

      {:error, changeset} ->
        conn |> put_status(:unprocessable_entity) |> json(%{errors: format_errors(changeset)})
    end
  end

get_pet — GET /pets/:integer

Look up a single pet by primary key:

  def get_pet(conn, %{"integer" => id}) do
    case Repo.get(Pet, id) do
      nil ->
        conn |> put_status(:not_found) |> json(%{error: "not found"})

      pet ->
        json(conn, Pet.to_json(pet))
    end
  end

list_pets — GET /pets

Return all pets as a JSON array:

  def list_pets(conn, _params) do
    pets = Repo.all(Pet)
    json(conn, Enum.map(pets, &Pet.to_json/1))
  end

Error formatting helper

Add a private function to translate changeset errors into JSON:

  defp format_errors(changeset) do
    Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
      Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
        opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
      end)
    end)
  end
end

The Ecto schema, changeset validations, and Dox validation bridging are all generated for you — the Pet.changeset/2 call handles both standard Ecto validations (validate_required) and your Paradox valid rules (e.g. name != "").

The Pet.to_json/1 helper is also generated, converting schema structs to plain maps suitable for JSON encoding.

Step 5: Test It

# Create a pet
curl -X POST http://localhost:4000/api/pets \
  -H "Content-Type: application/json" \
  -d '{"name": "Buddy", "species": "Dog", "age": 3}'

# List all pets
curl http://localhost:4000/api/pets

# Get a pet
curl http://localhost:4000/api/pets/1

# Validation error -- empty name
curl -X POST http://localhost:4000/api/pets \
  -H "Content-Type: application/json" \
  -d '{"name": "", "species": "Cat", "age": 2}'
# => 422 Unprocessable Entity

Optional: Add LiveView

Re-run the generator with --live for stub LiveView pages:

paradox-phoenix --title "PetShop" --uris createPet --uris getPet --uris listPets --live

This adds a browser scope with HomeLive and stub LiveView index pages. Open http://localhost:4000/ to see the home page with links to your API endpoints.

Next Steps