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
- 1Create the project folder and a virtual environment.
- 2Install FastAPI and friends.
- 3Prompt the AI with the contract.
- 4Check the AI's code with a review checklist.
- 5Run the server and try it in the browser.
- 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.
mkdir brew-and-bean-api
cd brew-and-bean-api
python -m venv .venvCreates 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.
# Windows PowerShell
.venv\Scripts\Activate.ps1
# Windows Command Prompt
.venv\Scripts\activate.bat
# macOS, Linux, Git Bash
source .venv/bin/activateIf 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.
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.
-r requirements.txt
pytest
httpxEverything in requirements.txt plus packages used only for testing (lesson 4). Hosting does not need these.
pip install -r requirements-dev.txtInstalls everything into the active environment. It takes a minute.
.venv/
__pycache__/
*.db
.pytest_cache/
.envKeeps 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.
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
- The total is built from the server's menu prices (look for a loop that uses the menu, not the request).
- An unknown item_id gives an error and does not crash the server.
- The order id is random (secrets), not a counter.
- The status endpoint checks the admin key and also checks the allowed moves.
- The admin key is read from the environment, never typed into the code.
- CORS allows only the origins you set, not "*".
- There is no endpoint that lists all orders.
- 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.
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: OrderStatusEach 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.
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.
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 orderThe 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.
# 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 --reloadapp.main:app means: in the file app/main.py, use the object named app. --reload restarts the server when you save a file.
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):
{
"customer_name": "Asha",
"phone": "9876543210",
"items": [
{"item_id": "espresso", "qty": 2},
{"item_id": "croissant", "qty": 1}
],
"pickup": "in_15"
}{
"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.
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 this | Expect |
|---|---|
| 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 21 | 422 |
| POST /orders with an empty items list | 422 |
| GET /orders/abc | 404, Order not found |
| PATCH status without the key | 401 |
| PATCH status to "ready" for a new order | 409, Cannot move from received to ready |
| PATCH status to "teleported" | 422 |
| Restart the server, then GET your old order | 404 (it was only in memory) |
Common problems
Troubleshooting
| Problem | Cause 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 use | Another program uses port 8000. Stop it, or run uvicorn with --port 8001. |
| Every PATCH returns 503 | ADMIN_KEY is not set in the terminal that runs uvicorn. Set it and restart. |
| 422 on every POST | The 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 None | Python is older than 3.10. Install a newer Python. |
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
- FastAPI turns type-hinted Pydantic models into validation, documentation and response shaping.
- Prompt with the contract, forbid extras, and review the code with a checklist before running it.
- The server computes totals and copies prices from its own menu, so customers cannot change them.
- Use /docs to try endpoints, and try to break your own API with bad input.
- 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.