ποΈ Part 8 β Backend Capstone Project
Goal: Build a complete, working backend API system from scratch: FastAPI backend, PostgreSQL database, Docker Compose orchestration, and a Python test script to verify end-to-end behavior.
Navigation
| β Part 7: Advanced Git | Part 9: Final Evaluation β |
1. System Overview
| Layer | Technology |
|---|---|
| API | FastAPI (Python) |
| Database | PostgreSQL |
| Orchestration | Docker Compose |
| Testing | Python requests script |
Final System Flow
test_api.py β HTTP request β FastAPI Backend β PostgreSQL β Response β test_api.py
2. Required Features
Backend
POST /usersβ create a user withname,email,age; return201with the new userGET /usersβ return all users as a JSON arrayGET /users/{user_id}β return one user by id; return404if not foundGET /β health check returning{"status": "ok"}
Database
- Users are stored persistently in PostgreSQL
- Data must survive a container restart
Docker
- Full system starts with one command:
docker compose up --build
3. Project Structure
project/
βββ backend/
β βββ app/
β β βββ main.py # FastAPI app entry point
β β βββ routes/
β β β βββ users.py # User API endpoints
β β βββ services/
β β β βββ user_service.py # Business logic
β β βββ schemas/
β β β βββ user.py # Pydantic request/response models
β β βββ db/
β β βββ connection.py # Database connection helper
β βββ init.sql # SQL to create tables on first startup
β βββ requirements.txt # Python dependencies
β βββ Dockerfile # Container build instructions
βββ test_api.py # Python script to verify end-to-end behavior
βββ docker-compose.yml # Orchestrates backend + database containers
4. Backend Implementation
Pydantic Schema
backend/app/schemas/user.py
from pydantic import BaseModel # Base class for all data validation schemas
# Schema for incoming requests β what the client must send to create a user
class UserCreate(BaseModel):
name: str # Required; any string
email: str # Required; ideally unique in the database
age: int # Required; must be an integer
# No Config class needed here β UserCreate is not read from database rows
# Schema for outgoing responses β what the API returns to the client
class UserResponse(BaseModel):
id: int # Assigned by the database when the row is inserted
name: str
email: str
age: int
class Config:
from_attributes = True # Required for response models that map from DB row objects
Database Connection
backend/app/db/connection.py
import psycopg2 # PostgreSQL driver for Python
import os # Read environment variables (credentials, hostnames)
def get_connection():
# All connection details come from environment variables set in docker-compose.yml
# Never hardcode passwords in source code
return psycopg2.connect(
host=os.getenv("DB_HOST", "db"), # "db" is the Docker service name
database=os.getenv("DB_NAME", "appdb"),
user=os.getenv("DB_USER", "postgres"),
password=os.getenv("DB_PASSWORD", "password"),
)
Service Layer
backend/app/services/user_service.py
from app.db.connection import get_connection
from app.schemas.user import UserCreate
def create_user(user: UserCreate) -> dict:
conn = get_connection() # Open a connection to the database
try:
with conn.cursor() as cur:
# Parameterised query β %s placeholders are filled safely by psycopg2
# RETURNING id gives us back the auto-generated id
cur.execute(
"INSERT INTO users (name, email, age) VALUES (%s, %s, %s) RETURNING id",
(user.name, user.email, user.age),
)
user_id = cur.fetchone()[0] # Extract the new id from the result
conn.commit() # Make the insert permanent
return {"id": user_id, "name": user.name, "email": user.email, "age": user.age}
finally:
conn.close() # Always close the connection
def get_users() -> list:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT id, name, email, age FROM users ORDER BY id")
rows = cur.fetchall() # List of tuples: [(1, "Alice", ...), ...]
# Convert each tuple to a dict for JSON serialisation
return [{"id": r[0], "name": r[1], "email": r[2], "age": r[3]} for r in rows]
finally:
conn.close()
def find_user(user_id: int) -> dict | None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT id, name, email, age FROM users WHERE id = %s", (user_id,))
row = cur.fetchone() # Returns None if no row matches
if row is None:
return None # Caller will raise HTTPException 404
return {"id": row[0], "name": row[1], "email": row[2], "age": row[3]}
finally:
conn.close()
Routes
backend/app/routes/users.py
from fastapi import APIRouter, HTTPException
from app.schemas.user import UserCreate, UserResponse
from app.services import user_service
# All routes in this file share the /users prefix and "users" tag in /docs
router = APIRouter(prefix="/users", tags=["users"])
# POST /users β create a new user; returns 201 on success
@router.post("/", response_model=UserResponse, status_code=201)
def create_user(user: UserCreate):
return user_service.create_user(user)
# GET /users β return all users as a list
@router.get("/", response_model=list[UserResponse])
def get_users():
return user_service.get_users()
# GET /users/{user_id} β return one user by id
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
user = user_service.find_user(user_id)
if user is None:
# HTTPException tells FastAPI to return a JSON error response
raise HTTPException(status_code=404, detail="User not found")
return user
Application Entry Point
backend/app/main.py
from fastapi import FastAPI # The main FastAPI class
from fastapi.middleware.cors import CORSMiddleware # Allows cross-origin requests
from app.routes import users # Import the users router
app = FastAPI(title="User API") # Title appears in /docs
# CORS middleware lets external clients (browsers, test scripts) call the API
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow any origin in development
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(users.router) # Register all /users routes
# Health check β a quick way to confirm the server is up and responding
@app.get("/")
def health_check():
return {"status": "ok"}
5. Database Initialisation
Before the backend can insert users, the table must exist. Create an SQL init script:
backend/init.sql
-- Create the users table on first database startup
-- IF NOT EXISTS prevents an error if the table already exists
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, -- Auto-incrementing integer ID
name VARCHAR(100) NOT NULL, -- User's display name; required
email VARCHAR(255) UNIQUE NOT NULL, -- Must be unique; required
age INTEGER -- Optional age field
);
Mount it in docker-compose.yml under the db service so PostgreSQL runs it automatically on first start:
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: appdb
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql
6. Docker Setup
Backend Dockerfile
backend/Dockerfile
# Start from the official Python 3.11 slim image
FROM python:3.11-slim
# All commands run from /app inside the container
WORKDIR /app
# Copy requirements first β lets Docker cache the pip install layer
COPY requirements.txt .
# Install dependencies; --no-cache-dir keeps the image smaller
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application code
COPY . .
# Document that this container serves traffic on port 8000
EXPOSE 8000
# Start the FastAPI server; --host 0.0.0.0 makes it reachable from outside
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Docker Compose
docker-compose.yml
version: "3.9" # Docker Compose file format version
services:
# ββ Backend ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
backend:
build: ./backend # Build from backend/Dockerfile
ports:
- "8000:8000" # Host port 8000 β Container port 8000
environment:
- DB_HOST=db # Must be the service name "db", not "localhost"
- DB_NAME=appdb
- DB_USER=postgres
- DB_PASSWORD=password
depends_on:
- db # Start db container before backend
# ββ Database βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
db:
image: postgres:15 # Official PostgreSQL 15 image
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: appdb # Create this database on first run
volumes:
- postgres_data:/var/lib/postgresql/data # Persist data
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql # Run on first start
# Named volume managed by Docker β data persists across container restarts
volumes:
postgres_data:
7. Python Test Script
test_api.py
import requests # install with pip (Windows) or pip3 (Mac/Linux)
BASE_URL = "http://localhost:8000"
def test_health():
"""Verify the server is running and responding."""
response = requests.get(f"{BASE_URL}/")
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert response.json()["status"] == "ok"
print("β
Health check passed")
def test_create_user():
"""Create a user and verify the response."""
# Use a unique email each run to avoid duplicate key errors on repeated runs
# (the email column has a UNIQUE constraint in the database)
import time
unique_email = f"alice_{int(time.time())}@example.com"
response = requests.post(
f"{BASE_URL}/users",
json={"name": "Alice", "email": unique_email, "age": 28}
)
assert response.status_code == 201, f"Expected 201, got {response.status_code}"
data = response.json()
assert "id" in data, "Response should contain an id"
assert data["name"] == "Alice"
print(f"β
Create user passed β id={data['id']}")
return data["id"] # Return the id for use in later tests
def test_get_users():
"""Fetch all users and verify at least one is returned."""
response = requests.get(f"{BASE_URL}/users")
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
users = response.json()
assert isinstance(users, list), "Response should be a list"
assert len(users) > 0, "There should be at least one user"
print(f"β
Get users passed β {len(users)} user(s) found")
def test_get_user_not_found():
"""Request a non-existent user and verify 404 is returned."""
response = requests.get(f"{BASE_URL}/users/99999")
assert response.status_code == 404, f"Expected 404, got {response.status_code}"
print("β
Get unknown user returns 404 β passed")
def test_invalid_data():
"""Send invalid data and verify 422 is returned."""
response = requests.post(
f"{BASE_URL}/users",
json={"name": "Bob", "email": "bob@example.com", "age": "not-a-number"}
)
assert response.status_code == 422, f"Expected 422, got {response.status_code}"
print("β
Invalid data returns 422 β passed")
# Run all tests when the script is executed directly
if __name__ == "__main__":
try:
test_health()
test_create_user()
test_get_users()
test_get_user_not_found()
test_invalid_data()
print("\nπ All tests passed!")
except AssertionError as e:
print(f"\nβ Test failed: {e}")
except requests.exceptions.ConnectionError:
print("\nβ Could not connect to the server.")
print(" Make sure the server is running: docker compose up --build")
Run the tests after starting the system:
docker compose up --build -d
python test_api.py
8. Running the System
docker compose up --build
Verify:
| URL | Expected result |
|---|---|
http://localhost:8000 |
{"status": "ok"} |
http://localhost:8000/docs |
FastAPI interactive API documentation |
http://localhost:8000/users |
JSON array (empty on first run) |
9. Debugging System-Level Failures
| Symptom | What to check |
|---|---|
| Backend canβt reach DB | Is DB_HOST=db? Is the db container healthy? (docker compose ps) |
| Users not persisting | Is conn.commit() called? Does the users table exist? |
| Container crashes on start | docker compose logs <service> β read the stack trace |
init.sql didnβt run |
Volume already exists from a previous run β docker compose down -v, then restart |
10. Delivery Checklist
Before opening the PR, verify the system from a clean checkout:
docker compose up --buildstarts all services without errorsGET /returns{"status": "ok"}POST /userscreates a user and returns201GET /usersreturns persisted users from PostgreSQLGET /users/{id}returns the user;GET /users/99999returns404- Restarting containers does not delete user data
- Invalid input returns
422with a clear error python test_api.pyruns and all tests pass- Logs do not expose secrets
11. Deliverables
You must deliver:
- Backend with
POST /users,GET /users, andGET /users/{user_id}connected to PostgreSQL - Database that persists users across restarts
- Full system runs with
docker compose up --build test_api.pythat exercises all endpoints- Code in a Git repository with a feature branch and PR
- PR description includes test evidence and any known limitations
12. Evaluation Criteria
| Area | What is assessed |
|---|---|
| Functionality | Do all endpoints work correctly? |
| Persistence | Does data survive a container restart? |
| Code structure | Are routes, services, schemas, and DB separated correctly? |
| Docker | Does docker compose up --build start everything cleanly? |
| Testing | Does test_api.py pass? Were error cases tested? |
| Git | Was the work done in a branch? Is there a PR? Are commit messages meaningful? |
Part 8 Summary
This part brings everything together. Building the full system requires every skill from Parts 1β7:
| Part | Contribution |
|---|---|
| Part 1 | Environment setup, Git, HTTP understanding |
| Part 2 | FastAPI routes, schemas, services |
| Part 3 | Virtual environments, pip, Python test scripts |
| Part 4 | Dockerfile and Docker Compose |
| Part 5 | PostgreSQL queries with psycopg2 |
| Part 6 | Debugging tools and methodology |
| Part 7 | Branch workflow, PR, commit messages |
Navigation
| β Part 7: Advanced Git | Part 9: Final Evaluation β |