Build Websites and Apps with AI
ProjectBeginner 45 min· Project: coffee shop backend · 13 of 21

Build the API with FastAPI

Build the Brew & Bean order API in FastAPI from your contract: menu, orders, status updates, validation and an admin key. You prompt AI, review its code with a checklist, run it and try to break it.

You have the contract. Now you build the API with FastAPI, a popular Python framework for web APIs. By the end of this lesson you will have a server running on your laptop that returns the menu, accepts orders and lets staff update their status. Orders are kept in memory for now: they disappear when the server restarts. The next lesson adds a database.

You need: Python 3.10 or newer (check with python --version; on some systems the command is python3), the API.md from the last lesson, a code editor such as VS Code and a free AI assistant. If you have not installed Python, the setup lesson of this track shows how.

What you will build

A folder brew-and-bean-api with these files, and the server answering at http://localhost:8000. FastAPI also writes a documentation page for you at /docs, where you can try every endpoint without writing any front-end code.

What you will do

  1. 1Create the project folder and a virtual environment.
  2. 2Install FastAPI and friends.
  3. 3Prompt the AI with the contract.
  4. 4Check the AI's code with a review checklist.
  5. 5Run the server and try it in the browser.
  6. 6Try it from the terminal and break it on purpose.

Step 1: Set up the project

A virtual environment is a private folder of Python packages for one project, so projects do not clash. Always create one. Open a terminal in the folder where you keep your projects.

Terminal
mkdir brew-and-bean-api
cd brew-and-bean-api
python -m venv .venv

Creates the project folder and an environment in the hidden folder .venv. On macOS and Linux, use python3 if python is not found.

Now activate it. You must do this in every new terminal window. The prompt shows (.venv) when it is active.

Terminal
# Windows PowerShell
.venv\Scripts\Activate.ps1

# Windows Command Prompt
.venv\Scripts\activate.bat

# macOS, Linux, Git Bash
source .venv/bin/activate

If PowerShell blocks the script, run: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned and try again. In Git Bash on Windows use: source .venv/Scripts/activate

Create two small files that list the packages you need. Putting them in files means anyone (and your hosting service later) can install exactly the same things with one command.

requirements.txt
fastapi
uvicorn
sqlalchemy
psycopg[binary]

The packages the server needs. FastAPI is the framework. Uvicorn is the program that runs it. SQLAlchemy and psycopg are for the database in lesson 3: installing them now saves a step.

requirements-dev.txt
-r requirements.txt
pytest
httpx

Everything in requirements.txt plus packages used only for testing (lesson 4). Hosting does not need these.

Terminal
pip install -r requirements-dev.txt

Installs everything into the active environment. It takes a minute.

.gitignore
.venv/
__pycache__/
*.db
.pytest_cache/
.env

Keeps the environment, caches, local database files and secret files out of Git.

Step 2: Prompt the AI with the contract

Paste your API.md into the prompt. The contract does the hard work: you do not have to explain each rule again. Ask for the code in the exact structure you planned, and ask for no extras. Without that line, assistants often add login, a database or Docker files you did not ask for.

Prompt · Build the API from the contract
You are a senior Python developer. Build a FastAPI application from the API contract below.

Rules:
- Python 3.10+, FastAPI and Pydantic v2. No database yet: keep orders in a Python dict in memory.
- Files: app/main.py (endpoints), app/schemas.py (Pydantic models), app/menu.py (the 8 menu items as data).
- The server computes the total and copies name and price from the menu. The client never sends prices.
- Order ids come from the secrets module, not from a counter.
- Staff status changes need the X-Admin-Key header compared with the ADMIN_KEY environment variable. Use secrets.compare_digest.
- Add CORS middleware. Allowed origins come from the CORS_ORIGINS environment variable (comma separated), default http://localhost:5173.
- Use the exact field names, status codes and rules in the contract. Do not add endpoints, authentication systems or other features.
- Add a short comment above anything non-obvious.

Contract:
[paste API.md here]

Return each file in a separate code block with its path as a title.

What to expect: You should get three or four files. If the AI skipped CORS, added a /orders list endpoint, or computes the total from the request, ask it to fix that before continuing.

Step 3: Review before you run

Run the AI's code only after you have read it. You do not have to understand every line yet, but you can check the points that matter for safety and correctness. Use this list.

AI code review checklist

  1. The total is built from the server's menu prices (look for a loop that uses the menu, not the request).
  2. An unknown item_id gives an error and does not crash the server.
  3. The order id is random (secrets), not a counter.
  4. The status endpoint checks the admin key and also checks the allowed moves.
  5. The admin key is read from the environment, never typed into the code.
  6. CORS allows only the origins you set, not "*".
  7. There is no endpoint that lists all orders.
  8. Pydantic models limit the lengths and ranges from the contract (min and max).

Here is a reference implementation that follows the contract. If your AI's answer is different but passes the checklist, keep yours. If you are stuck, use these files. First the models, which describe the shape of data going in and out. This is the file where validation lives.

app/schemas.py
from datetime import datetime
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator

OrderStatus = Literal["received", "preparing", "ready", "picked_up", "cancelled"]
Pickup = Literal["asap", "in_15", "in_30", "in_60"]


class MenuItem(BaseModel):
    id: str
    name: str
    category: Literal["coffee", "tea", "bakery", "food"]
    price: int  # whole rupees
    description: str


class OrderItemIn(BaseModel):
    item_id: str
    qty: int = Field(ge=1, le=20)


class OrderIn(BaseModel):
    customer_name: str = Field(min_length=2, max_length=60)
    phone: str = Field(pattern=r"^[6-9]\d{9}$")
    items: list[OrderItemIn] = Field(min_length=1, max_length=20)
    pickup: Pickup = "asap"

    @field_validator("customer_name")
    @classmethod
    def strip_name(cls, value: str) -> str:
        value = value.strip()
        if len(value) < 2:
            raise ValueError("Name must have at least 2 characters")
        return value

    @field_validator("items")
    @classmethod
    def no_duplicate_items(cls, items: list[OrderItemIn]) -> list[OrderItemIn]:
        ids = [line.item_id for line in items]
        if len(ids) != len(set(ids)):
            raise ValueError("Each item may appear only once")
        return items


class OrderItemOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    item_id: str
    name: str
    price: int  # price per item when the order was placed
    qty: int


class OrderOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: str
    customer_name: str
    phone: str
    items: list[OrderItemOut]
    total: int
    status: OrderStatus
    pickup_time: datetime
    created_at: datetime


class StatusUpdate(BaseModel):
    status: OrderStatus

Each class is a Pydantic model. FastAPI uses them to check requests, to build the documentation and to shape responses.

A few lines worth understanding:

- Field(ge=1, le=20) means greater-or-equal 1 and less-or-equal 20. min_length and pattern work on text. - Literal[...] limits a value to a fixed set of strings. A pickup of "tomorrow" is rejected before your code runs. - @field_validator runs your own check. Here it trims spaces from the name and rejects duplicate items. - from_attributes=True lets Pydantic read values from objects such as database rows. It is unused today and needed in lesson 3.

app/menu.py
from app.schemas import MenuItem

MENU: list[MenuItem] = [
    MenuItem(id="espresso", name="Espresso", category="coffee", price=120,
             description="A short, strong shot made from freshly ground beans."),
    MenuItem(id="cappuccino", name="Cappuccino", category="coffee", price=180,
             description="Espresso with steamed milk and a thick layer of foam."),
    MenuItem(id="cold-brew", name="Cold Brew", category="coffee", price=220,
             description="Coffee steeped slowly in cold water and served over ice."),
    MenuItem(id="filter-coffee", name="Filter Coffee", category="coffee", price=80,
             description="South Indian style filter coffee with hot, frothy milk."),
    MenuItem(id="masala-chai", name="Masala Chai", category="tea", price=90,
             description="Black tea simmered with milk, ginger and warming spices."),
    MenuItem(id="croissant", name="Butter Croissant", category="bakery", price=150,
             description="Flaky, golden and baked fresh every morning."),
    MenuItem(id="banana-bread", name="Banana Bread", category="bakery", price=130,
             description="A soft, moist slice made with ripe bananas."),
    MenuItem(id="veg-sandwich", name="Veg Grilled Sandwich", category="food", price=160,
             description="Grilled vegetables and cheese between toasted bread."),
]

# Look up an item by id in one step. The server uses this to find real prices.
MENU_BY_ID: dict[str, MenuItem] = {item.id: item for item in MENU}

The menu lives on the server. MENU_BY_ID is a dictionary built from the list so the server can find an item by id in one step.

Also create an empty file named app/__init__.py. It marks the folder as a Python package, so that lines like from app.menu import MENU work.

app/main.py
import os
import secrets
from datetime import datetime, timedelta, timezone

from fastapi import FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware

from app.menu import MENU, MENU_BY_ID
from app.schemas import (
    MenuItem,
    OrderIn,
    OrderItemOut,
    OrderOut,
    StatusUpdate,
)

app = FastAPI(title="Brew & Bean API")

# Which websites may call this API from a browser. Comma-separated list.
origins = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
app.add_middleware(
    CORSMiddleware,
    allow_origins=[origin.strip() for origin in origins],
    allow_methods=["GET", "POST", "PATCH"],
    allow_headers=["Content-Type", "X-Admin-Key"],
)

# Minutes from "now" for each pickup choice.
PICKUP_MINUTES = {"asap": 10, "in_15": 15, "in_30": 30, "in_60": 60}

# Which status may follow which. A finished order cannot change again.
NEXT_STATUS = {
    "received": {"preparing", "cancelled"},
    "preparing": {"ready", "cancelled"},
    "ready": {"picked_up"},
    "picked_up": set(),
    "cancelled": set(),
}

# For now orders live in memory and vanish when the server restarts.
ORDERS: dict[str, OrderOut] = {}


def require_admin(x_admin_key: str | None) -> None:
    expected = os.getenv("ADMIN_KEY")
    if not expected:
        raise HTTPException(503, "ADMIN_KEY is not set on the server")
    if not x_admin_key or not secrets.compare_digest(x_admin_key, expected):
        raise HTTPException(401, "Missing or wrong admin key")


@app.get("/menu", response_model=list[MenuItem])
def get_menu():
    return MENU


@app.post("/orders", response_model=OrderOut, status_code=201)
def create_order(order: OrderIn):
    lines = []
    total = 0
    for line in order.items:
        item = MENU_BY_ID.get(line.item_id)
        if item is None:
            raise HTTPException(422, f"Unknown item: {line.item_id}")
        total += item.price * line.qty
        lines.append(
            OrderItemOut(item_id=item.id, name=item.name, price=item.price, qty=line.qty)
        )

    now = datetime.now(timezone.utc)
    new_order = OrderOut(
        id=secrets.token_urlsafe(9),
        customer_name=order.customer_name,
        phone=order.phone,
        items=lines,
        total=total,
        status="received",
        pickup_time=now + timedelta(minutes=PICKUP_MINUTES[order.pickup]),
        created_at=now,
    )
    ORDERS[new_order.id] = new_order
    return new_order


@app.get("/orders/{order_id}", response_model=OrderOut)
def get_order(order_id: str):
    order = ORDERS.get(order_id)
    if order is None:
        raise HTTPException(404, "Order not found")
    return order


@app.patch("/orders/{order_id}/status", response_model=OrderOut)
def update_status(
    order_id: str,
    update: StatusUpdate,
    x_admin_key: str | None = Header(default=None),
):
    require_admin(x_admin_key)
    order = ORDERS.get(order_id)
    if order is None:
        raise HTTPException(404, "Order not found")
    if update.status not in NEXT_STATUS[order.status]:
        raise HTTPException(409, f"Cannot move from {order.status} to {update.status}")
    order.status = update.status
    return order

The whole API in one file: CORS, the status rules, and the four endpoints.

Walk through main.py once:

- @app.get("/menu", response_model=list[MenuItem]) registers a function for one method and path. response_model tells FastAPI what shape the answer has, and it filters out anything else. - In create_order, the parameter order: OrderIn is how you receive the request body. FastAPI reads the JSON, checks it with the Pydantic model and answers 422 on its own if it is invalid. Your function only runs with valid data. - The loop builds each line from MENU_BY_ID, so a name and a price always come from the server. - raise HTTPException(404, ...) stops the function and sends that status and message. - Header(default=None) reads the X-Admin-Key header. FastAPI turns x_admin_key into that header name automatically.

Step 4: Run it

Make sure the virtual environment is active and you are in the folder that contains app. Set the admin key for this terminal window, then start the server. The key can be any text for now.

Terminal
# Windows PowerShell
$env:ADMIN_KEY = "dev-admin-key"

# macOS, Linux, Git Bash
export ADMIN_KEY=dev-admin-key

# then, on every system
uvicorn app.main:app --reload

app.main:app means: in the file app/main.py, use the object named app. --reload restarts the server when you save a file.

What you should see (paths and numbers differ)
INFO:     Will watch for changes in these directories: ['.../brew-and-bean-api']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [12345] using WatchFiles
INFO:     Application startup complete.

Open http://localhost:8000/docs in your browser. You will see an interactive page listing the four endpoints. Click GET /menu, then Try it out, then Execute. You should see the eight menu items as JSON. Then try POST /orders with this body and check that the total is 390 (2 x 120 + 150):

Request body for POST /orders
{
  "customer_name": "Asha",
  "phone": "9876543210",
  "items": [
    {"item_id": "espresso", "qty": 2},
    {"item_id": "croissant", "qty": 1}
  ],
  "pickup": "in_15"
}
Response with status 201 (id and times differ)
{
  "id": "VB24Zt9CIaFb",
  "customer_name": "Asha",
  "phone": "9876543210",
  "items": [
    {"item_id": "espresso", "name": "Espresso", "price": 120, "qty": 2},
    {"item_id": "croissant", "name": "Butter Croissant", "price": 150, "qty": 1}
  ],
  "total": 390,
  "status": "received",
  "pickup_time": "2026-10-31T12:12:58.220206Z",
  "created_at": "2026-10-31T11:57:58.220206Z"
}

Copy the id from your response. Use GET /orders/{id} to read the order back. Then try PATCH /orders/{id}/status with {"status": "preparing"}. FastAPI shows the x-admin-key header as an input box on that endpoint. Leave it empty the first time and you get 401. Then type dev-admin-key (or whatever you set) and run it again.

Step 5: Try it from the terminal

curl sends requests from the command line. It is built into macOS, Linux and Git Bash. In Windows PowerShell, curl is a different command, so use curl.exe or the docs page instead. Replace ORDER_ID with your id.

Terminal
curl http://localhost:8000/menu

curl -X POST http://localhost:8000/orders -H "Content-Type: application/json" -d '{"customer_name":"Asha","phone":"9876543210","items":[{"item_id":"espresso","qty":2}]}'

curl http://localhost:8000/orders/ORDER_ID

curl -X PATCH http://localhost:8000/orders/ORDER_ID/status -H "Content-Type: application/json" -H "X-Admin-Key: dev-admin-key" -d '{"status":"preparing"}'

Each command prints JSON. The last one prints the order with status preparing.

Step 6: Break it on purpose

A good habit: after the happy path works, try to break your own API. Each failure below should give a clean error, never a crash (a 500 code). Try them in the docs page or with curl.

Failures to try

Try thisExpect
POST /orders with phone "12345"422 with a message about the phone pattern
POST /orders with item_id "pizza"422, Unknown item: pizza
POST /orders with qty 0 or 21422
POST /orders with an empty items list422
GET /orders/abc404, Order not found
PATCH status without the key401
PATCH status to "ready" for a new order409, Cannot move from received to ready
PATCH status to "teleported"422
Restart the server, then GET your old order404 (it was only in memory)

Common problems

Troubleshooting

ProblemCause and fix
ModuleNotFoundError: No module named 'app'You are not in the project folder, or app/__init__.py is missing. Run uvicorn from the folder that contains app.
ModuleNotFoundError: No module named 'fastapi'The virtual environment is not active. Activate it and check the (.venv) prefix.
Address already in useAnother program uses port 8000. Stop it, or run uvicorn with --port 8001.
Every PATCH returns 503ADMIN_KEY is not set in the terminal that runs uvicorn. Set it and restart.
422 on every POSTThe JSON does not match the models. Read the loc field in the error: it names the exact field.
SyntaxError on a type hint such as str or NonePython is older than 3.10. Install a newer Python.
Prompt · Explain this error
I am building a FastAPI app. Here is my project layout, the command I ran and the full error. Explain in simple words what the error means, the most likely cause and the exact fix. Do not rewrite my whole code.

Layout:
[paste the folder tree]

Command:
[command]

Error:
[paste the full error]

What to expect: A good answer names the file and line, explains the cause in one or two sentences and shows a small change. If the AI suggests a large rewrite, push back.

What is still missing

This server works but has limits you now know about: orders vanish on restart (fixed in the next lesson), there are no automated tests (lesson 4), and no website uses it yet (lesson 5). The code also keeps orders in a global dictionary, which is fine for one process but is one more reason a real database is needed.

Try it: Add a rule: no order above Rs 5,000

Add a rule that rejects an order whose total is more than 5,000 rupees with status 422 and a clear message. Where does the check belong, and why not in the website only?

Try it: Stretch: a health check

Add GET /health that returns {"status": "ok"}. Hosting services use a path like this to check that your server is alive. Then ask your AI to explain what you would put in a health check for a server with a database.

Key takeaways

  1. FastAPI turns type-hinted Pydantic models into validation, documentation and response shaping.
  2. Prompt with the contract, forbid extras, and review the code with a checklist before running it.
  3. The server computes totals and copies prices from its own menu, so customers cannot change them.
  4. Use /docs to try endpoints, and try to break your own API with bad input.
  5. Keep secrets such as ADMIN_KEY in environment variables, not in code.

Check yourself

Finishing the quiz marks the lesson as done.

1. In FastAPI, what happens when a POST body does not match your Pydantic model?

2. Why does the order code look up each item in MENU_BY_ID instead of reading a price from the request?

3. You run uvicorn and get "No module named 'app'". What is the most likely cause?

4. Why is the admin key compared with secrets.compare_digest instead of ==?

5. After you restart the server, GET /orders/{id} for an earlier order returns 404. Why?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free