Time: 60–90 minutes (this one's bigger than Project 1 — take breaks) Stack: Python 3.9+, FastAPI, SQLAlchemy, SQLite, pytest Editor: VS Code What you'll build: A real REST API with full CRUD over a database of 25 songs, searchable by tags, plus a "random song for my mood" endpoint. Auto-generated API docs. Logging. Metrics. Tests. The works.
In Project 1 you were a client — you called someone else's API. Now you're the kitchen. You build the waiter that other apps would talk to.
The shift is huge. As a client, you only think about: what do I ask for, what do I get back. As an API author, you think about everything else: data modeling, validation, error responses, status codes, documentation, persistence, concurrency. This is what your dad's whole team does at AWS, just at a billion times the scale.
By the end of this you'll have:
- A working API running on your laptop with 8 endpoints
- A real SQLite database with 25 seeded songs
- Auto-generated interactive API docs (you'll love this — FastAPI does this for free)
- Logging that traces every request
- Metrics you can hit via an endpoint
- Tests that hit your API in-process (no need to run a server to test it)
- A clean GitHub repo
mkdir song-library-api
cd song-library-api
code .python3 -m venv venvActivate:
- Mac/Linux:
source venv/bin/activate - Windows PowerShell:
venv\Scripts\Activate.ps1 - Windows cmd:
venv\Scripts\activate.bat
pip install fastapi uvicorn "sqlalchemy>=2.0" pydantic pytest httpx
pip freeze > requirements.txtQuick tour of what we just installed:
- fastapi — the framework. Modern, fast, type-safe, auto-generates docs.
- uvicorn — the actual web server that runs FastAPI apps.
- sqlalchemy — the most popular Python ORM (Object-Relational Mapper). Lets you talk to a database using Python objects instead of raw SQL.
- pydantic — data validation. FastAPI uses it under the hood.
- pytest — testing framework (same as Project 1).
- httpx — HTTP client. FastAPI's
TestClientuses it to call your API in tests.
python -c "import fastapi, sqlalchemy, pydantic; print('all good')"If you see all good, you're set. If there's an error, fix it before continuing.
In VS Code, build this layout:
song-library-api/
├── app/
│ ├── __init__.py # empty file — marks the folder as a Python package
│ ├── main.py # FastAPI app + route handlers
│ ├── database.py # DB engine, session, base model
│ ├── models.py # SQLAlchemy table definitions
│ ├── schemas.py # Pydantic request/response models
│ └── metrics.py # in-memory metrics
├── tests/
│ ├── __init__.py # empty
│ └── test_api.py # API tests
├── seed.py # script to populate the DB with 25 songs
├── requirements.txt # already created above
├── .gitignore
└── README.md
__init__.py files are empty files that tell Python "this folder is a package." You can create them in VS Code with right-click → New File.
Same drill as Project 1.
"Build a personal song library where I can store songs with tags and search them by mood."
Resources: songs. That's the noun. Songs have:
id(auto-assigned)title(required)artist(required)album(optional)year(optional)tags(a list of 5 short labels likehappy,kpop,focus)created_at(when added)
Endpoints I need:
| Method | Path | Purpose |
|---|---|---|
| GET | /songs |
List all songs (optionally filter by tag) |
| GET | /songs/{id} |
Get one song |
| POST | /songs |
Add a new song |
| PUT | /songs/{id} |
Update an existing song |
| DELETE | /songs/{id} |
Delete a song |
| GET | /songs/search |
Search by multiple tags |
| GET | /songs/random |
Get a random song matching a mood |
| GET | /tags |
List all tags currently in use |
| GET | /metrics |
Get observability metrics |
What can go wrong:
- User asks for a song ID that doesn't exist → 404
- User sends a song with no title → 422 (validation error, FastAPI does this for free)
- User sends fewer than 1 or more than 10 tags → 422
- User searches for a tag that no song has → empty list (not an error)
- User tries to delete a song that doesn't exist → 404
- Database is locked → 500
Each song has a list of short string tags. Examples:
- Moods:
happy,sad,chill,melancholic,nostalgic,energetic - Contexts:
focus,study,workout,gym,driving,party,dinner - Genres:
kpop,pop,rock,indie,jazz,classical,hiphop,lofi
Tags are free-form strings — users can invent new ones. The API doesn't restrict the vocabulary, only the count (5 per song, give or take).
Picture this:
┌──────────────────┐
HTTP request ──►│ main.py │ routes + handlers
│ (FastAPI app) │
└────────┬─────────┘
│ uses
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ schemas │ │ database │ │ metrics │
│(Pydantic)│ │ + models │ │ │
│ │ │(SQLAlchemy)│ │
└──────────┘ └────┬─────┘ └──────────┘
│
▼
┌──────────┐
│songs.db │ SQLite file on disk
└──────────┘
The pattern:
- A request comes in to a route in
main.py - The route uses schemas (Pydantic) to validate the input
- It uses models (SQLAlchemy) to read/write the database
- It records a metric
- It returns a Pydantic schema as the response
This is FastAPI's standard architecture. Once you see it once, you see it in every FastAPI project.
A "model" in SQLAlchemy is a Python class that maps to a database table. Each attribute is a column.
"""
app/models.py — SQLAlchemy ORM models.
These classes are how we read and write the database in Python terms.
A `Song` object IS a row in the `songs` table.
"""
from datetime import datetime
from sqlalchemy import Integer, String, JSON, DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# Every SQLAlchemy model inherits from a Base class. We define it once here.
class Base(DeclarativeBase):
pass
class Song(Base):
# __tablename__ tells SQLAlchemy what to call the actual table in the DB.
__tablename__ = "songs"
# PYTHON BASIC: class attributes. These become columns in the table.
# Mapped[int] is a type hint — modern SQLAlchemy 2.0 style.
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
artist: Mapped[str] = mapped_column(String(200), nullable=False)
album: Mapped[str] = mapped_column(String(200), nullable=True)
year: Mapped[int] = mapped_column(Integer, nullable=True)
# JSON column — stores the list of tags as a JSON array.
# SQLite supports this natively. In a real production system with a lot
# of tag-based querying, you'd probably model tags as a separate table
# with a many-to-many relationship. For our scale, JSON is simpler and fine.
tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
def __repr__(self) -> str:
# PYTHON BASIC: __repr__ is what shows up when you print() the object.
# Useful for debugging.
return f"<Song id={self.id} title={self.title!r} artist={self.artist!r}>"A few things to notice:
- We never write SQL ourselves. SQLAlchemy generates it from these class definitions.
- The type hints aren't just documentation — they actually control the column types.
nullable=Falsemeans the column is required.
SQLAlchemy models describe what's in the database. Pydantic schemas describe what's in API requests and responses. They look similar but they're different things.
Why both? Because what a user sends to your API isn't always exactly what you store in the DB. A user shouldn't send an id when creating a song (the DB picks one). A user shouldn't send created_at (the server sets it). Schemas let you control exactly what's allowed in and what goes out.
"""
app/schemas.py — Pydantic models for request and response bodies.
These describe the SHAPE OF DATA going in and out of the API.
They're separate from database models so we can validate, transform, and
control exactly what's exposed.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class SongBase(BaseModel):
"""Fields shared by create and update — the user-editable fields."""
title: str = Field(..., min_length=1, max_length=200)
artist: str = Field(..., min_length=1, max_length=200)
album: Optional[str] = Field(None, max_length=200)
year: Optional[int] = Field(None, ge=1900, le=2100)
# tags is a list of strings. We enforce length and per-tag character limits.
# FastAPI will automatically return a 422 if these aren't met. Free validation!
tags: list[str] = Field(..., min_length=1, max_length=10)
class SongCreate(SongBase):
"""Schema for POST /songs — incoming data when creating a song."""
pass
class SongUpdate(SongBase):
"""Schema for PUT /songs/{id} — incoming data when updating."""
pass
class SongRead(SongBase):
"""Schema for responses — what we send back to the client."""
id: int
created_at: datetime
# PYDANTIC v2 BASIC: this tells Pydantic to read from ORM objects
# (i.e., SQLAlchemy models) by attribute, not just from dicts.
model_config = ConfigDict(from_attributes=True)
class MetricsResponse(BaseModel):
"""Schema for GET /metrics."""
requests_total: int
requests_by_endpoint: dict[str, int]
songs_in_db: int
errors_total: intThis file creates the database engine and the session machinery FastAPI will use.
"""
app/database.py — database engine and session setup.
"""
import logging
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from app.models import Base
logger = logging.getLogger(__name__)
# Path to the SQLite database file. It'll be created at the project root.
# In a real app this would be in an env var, not hardcoded.
DATABASE_URL = "sqlite:///./songs.db"
# The engine is the lowest-level interface to the database.
# check_same_thread=False is a SQLite-specific quirk for FastAPI.
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
# A SessionLocal is a factory that produces database sessions.
# A "session" is one unit of work — a conversation with the DB.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db() -> None:
"""Create all tables defined on Base. Safe to call multiple times."""
logger.info("Initializing database schema")
Base.metadata.create_all(bind=engine)
def get_db():
"""
FastAPI dependency that gives a route handler a DB session.
The 'yield' makes this a generator — FastAPI calls it before the request,
yields the session, then runs the finally block after the response is sent.
This guarantees the session is always closed, even if the handler crashes.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()That get_db() function is FastAPI's dependency injection in action. We'll use it in every route that needs database access. It's elegant and you'll see it everywhere.
"""
app/metrics.py — simple in-memory observability metrics.
In production you'd push these to Prometheus, CloudWatch, Datadog etc.
Here we just keep counters and expose them via a /metrics endpoint.
Four metrics, same as Project 1's budget:
1. requests_total — every request that hits any endpoint
2. requests_by_endpoint — per-endpoint counts (useful for traffic shaping)
3. songs_in_db — set on demand from the DB
4. errors_total — every 4xx/5xx response
"""
from collections import defaultdict
from threading import Lock
# A Lock prevents two simultaneous requests from corrupting our counters.
# This is a real concurrency concern even in a tiny API.
_lock = Lock()
_state = {
"requests_total": 0,
"errors_total": 0,
"requests_by_endpoint": defaultdict(int),
}
def record_request(endpoint: str) -> None:
with _lock:
_state["requests_total"] += 1
_state["requests_by_endpoint"][endpoint] += 1
def record_error() -> None:
with _lock:
_state["errors_total"] += 1
def snapshot() -> dict:
"""Return a copy of the current metrics state."""
with _lock:
return {
"requests_total": _state["requests_total"],
"errors_total": _state["errors_total"],
"requests_by_endpoint": dict(_state["requests_by_endpoint"]),
}This is the big one. Take your time. Read the comments. Run it before you finish writing it — start the server early to see things working.
"""
app/main.py — FastAPI application with all routes.
Endpoints:
GET / health check
GET /songs list / filter songs
GET /songs/search search by multiple tags (AND or OR)
GET /songs/random random song matching a tag
GET /songs/{id} get one song
POST /songs create a song
PUT /songs/{id} update a song
DELETE /songs/{id} delete a song
GET /tags list all unique tags
GET /metrics observability metrics
"""
import logging
import random
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, Query, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app import metrics
from app.database import engine, get_db, init_db
from app.models import Song
from app.schemas import SongCreate, SongUpdate, SongRead, MetricsResponse
# ─── Logging configuration ─────────────────────────────────────────────────
# Configure once, at app startup. Modules get loggers via logging.getLogger.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
# ─── FastAPI app instance ──────────────────────────────────────────────────
app = FastAPI(
title="Song Library API",
description="A personal song library, searchable by mood and tags.",
version="1.0.0",
)
# ─── Startup hook: create tables if they don't exist ───────────────────────
@app.on_event("startup")
def on_startup():
init_db()
logger.info("Application started")
# ─── Middleware: log every request and record metrics ──────────────────────
# Middleware runs around every request — perfect for cross-cutting concerns.
@app.middleware("http")
async def log_and_meter(request: Request, call_next):
endpoint = f"{request.method} {request.url.path}"
logger.info("→ %s", endpoint)
metrics.record_request(endpoint)
response = await call_next(request)
if response.status_code >= 400:
metrics.record_error()
logger.warning("← %s [%d]", endpoint, response.status_code)
else:
logger.info("← %s [%d]", endpoint, response.status_code)
return response
# ─── Health check ──────────────────────────────────────────────────────────
@app.get("/")
def root():
return {"status": "ok", "service": "song-library-api"}
# ─── List songs (with optional tag filter) ─────────────────────────────────
@app.get("/songs", response_model=list[SongRead])
def list_songs(
tag: Optional[str] = Query(None, description="Filter by a single tag"),
db: Session = Depends(get_db),
):
"""List all songs. If `tag` is provided, only return songs with that tag."""
query = db.query(Song)
songs = query.all()
if tag:
# SQLite's JSON support varies — easiest to filter in Python.
# PYTHON BASIC: list comprehension with a condition.
songs = [s for s in songs if tag in s.tags]
logger.info("Filtered %d songs with tag=%s", len(songs), tag)
return songs
# ─── Search by multiple tags ───────────────────────────────────────────────
@app.get("/songs/search", response_model=list[SongRead])
def search_songs(
tags: list[str] = Query(..., description="Tags to match (e.g. ?tags=happy&tags=kpop)"),
match: str = Query("any", regex="^(any|all)$", description="any = OR match, all = AND match"),
db: Session = Depends(get_db),
):
"""
Search songs by tags.
match=any → song has AT LEAST ONE of the given tags
match=all → song has ALL of the given tags
"""
all_songs = db.query(Song).all()
tag_set = set(tags) # PYTHON BASIC: set for fast membership checks
if match == "all":
# The song's tags must be a superset of what we asked for
results = [s for s in all_songs if tag_set.issubset(set(s.tags))]
else:
# Any overlap counts
results = [s for s in all_songs if tag_set & set(s.tags)]
logger.info("Search tags=%s match=%s → %d results", tags, match, len(results))
return results
# ─── Random song by tag (the fun feature) ──────────────────────────────────
@app.get("/songs/random", response_model=SongRead)
def random_song(
tag: Optional[str] = Query(None, description="Pick from songs with this tag"),
db: Session = Depends(get_db),
):
"""Return one random song. If `tag` is set, only from songs with that tag."""
songs = db.query(Song).all()
if tag:
songs = [s for s in songs if tag in s.tags]
if not songs:
raise HTTPException(status_code=404, detail=f"No songs found for tag '{tag}'")
chosen = random.choice(songs)
logger.info("Random pick: %s (tag=%s)", chosen.title, tag)
return chosen
# ─── Get one song by ID ────────────────────────────────────────────────────
@app.get("/songs/{song_id}", response_model=SongRead)
def get_song(song_id: int, db: Session = Depends(get_db)):
song = db.get(Song, song_id)
if song is None:
raise HTTPException(status_code=404, detail=f"Song {song_id} not found")
return song
# ─── Create a song ─────────────────────────────────────────────────────────
@app.post("/songs", response_model=SongRead, status_code=status.HTTP_201_CREATED)
def create_song(payload: SongCreate, db: Session = Depends(get_db)):
"""Create a new song. Pydantic validates the payload before we get here."""
# PYTHON BASIC: ** unpacks a dict into keyword args.
# payload.model_dump() converts the Pydantic schema to a plain dict.
song = Song(**payload.model_dump())
db.add(song)
db.commit()
db.refresh(song) # refresh so we get the auto-generated id and created_at
logger.info("Created song id=%d title=%r", song.id, song.title)
return song
# ─── Update a song ─────────────────────────────────────────────────────────
@app.put("/songs/{song_id}", response_model=SongRead)
def update_song(song_id: int, payload: SongUpdate, db: Session = Depends(get_db)):
song = db.get(Song, song_id)
if song is None:
raise HTTPException(status_code=404, detail=f"Song {song_id} not found")
# PYTHON BASIC: iterating over dict.items() to set attributes.
for field, value in payload.model_dump().items():
setattr(song, field, value)
db.commit()
db.refresh(song)
logger.info("Updated song id=%d", song.id)
return song
# ─── Delete a song ─────────────────────────────────────────────────────────
@app.delete("/songs/{song_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_song(song_id: int, db: Session = Depends(get_db)):
song = db.get(Song, song_id)
if song is None:
raise HTTPException(status_code=404, detail=f"Song {song_id} not found")
db.delete(song)
db.commit()
logger.info("Deleted song id=%d", song_id)
# 204 No Content — convention for successful DELETE. No body.
return None
# ─── List all unique tags in the library ───────────────────────────────────
@app.get("/tags", response_model=list[str])
def list_tags(db: Session = Depends(get_db)):
"""Return every unique tag that appears on any song, sorted."""
songs = db.query(Song).all()
# PYTHON BASIC: set comprehension to collect unique values.
unique = {tag for song in songs for tag in song.tags}
return sorted(unique)
# ─── Metrics endpoint ──────────────────────────────────────────────────────
@app.get("/metrics", response_model=MetricsResponse)
def get_metrics(db: Session = Depends(get_db)):
snap = metrics.snapshot()
snap["songs_in_db"] = db.query(Song).count()
return snap
# ─── Global error handler ──────────────────────────────────────────────────
# Catch anything unexpected so we never leak a stack trace to the client.
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
metrics.record_error()
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)uvicorn app.main:app --reload--reload means the server restarts whenever you save a file. Critical for dev.
You should see:
INFO: Uvicorn running on http://127.0.0.1:8000
INFO: Application startup complete.
Now the magic moment. Open a browser to http://127.0.0.1:8000/docs.
You'll see a full interactive API explorer that FastAPI generated automatically from your code. You can try every endpoint right from the browser. This is one of the killer features of FastAPI — the documentation isn't a separate thing you write, it's a byproduct of writing the code correctly with types.
Try GET /songs — it'll return [] because the DB is empty. Let's fix that.
This script populates the DB with 25 real songs across diverse moods and genres.
"""
seed.py — populate the database with 25 starter songs.
Run with: python seed.py
Idempotent: deletes existing songs first so you can re-run anytime.
"""
import logging
from app.database import SessionLocal, init_db
from app.models import Song
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
# 25 songs, each with exactly 5 tags. Diverse moods, genres, and contexts.
SONGS = [
# K-pop
{"title": "Dynamite", "artist": "BTS", "album": "BE", "year": 2020,
"tags": ["kpop", "happy", "energetic", "pop", "dance"]},
{"title": "How You Like That","artist": "BLACKPINK", "album": "THE ALBUM", "year": 2020,
"tags": ["kpop", "energetic", "party", "dance", "hiphop"]},
{"title": "Super Shy", "artist": "NewJeans", "album": "Get Up", "year": 2023,
"tags": ["kpop", "happy", "chill", "pop", "summer"]},
{"title": "Spring Day", "artist": "BTS", "album": "You Never Walk Alone", "year": 2017,
"tags": ["kpop", "sad", "nostalgic", "ballad", "melancholic"]},
{"title": "LOVE DIVE", "artist": "IVE", "album": "LOVE DIVE", "year": 2022,
"tags": ["kpop", "energetic", "pop", "dance", "party"]},
# Pop / mainstream
{"title": "Blinding Lights", "artist": "The Weeknd", "album": "After Hours", "year": 2020,
"tags": ["pop", "energetic", "driving", "synthwave", "workout"]},
{"title": "Watermelon Sugar", "artist": "Harry Styles", "album": "Fine Line", "year": 2019,
"tags": ["pop", "happy", "summer", "chill", "romantic"]},
{"title": "Levitating", "artist": "Dua Lipa", "album": "Future Nostalgia", "year": 2020,
"tags": ["pop", "happy", "party", "dance", "workout"]},
{"title": "good 4 u", "artist": "Olivia Rodrigo", "album": "SOUR", "year": 2021,
"tags": ["pop", "energetic", "breakup", "rock", "angry"]},
{"title": "As It Was", "artist": "Harry Styles", "album": "Harry's House", "year": 2022,
"tags": ["pop", "nostalgic", "chill", "indie", "melancholic"]},
{"title": "Anti-Hero", "artist": "Taylor Swift", "album": "Midnights", "year": 2022,
"tags": ["pop", "melancholic", "indie", "chill", "introspective"]},
{"title": "Cruel Summer", "artist": "Taylor Swift", "album": "Lover", "year": 2019,
"tags": ["pop", "summer", "energetic", "party", "romantic"]},
{"title": "Espresso", "artist": "Sabrina Carpenter", "album": "Short n' Sweet", "year": 2024,
"tags": ["pop", "happy", "summer", "party", "dance"]},
# Focus / chill / instrumental
{"title": "Weightless", "artist": "Marconi Union", "album": "Weightless", "year": 2011,
"tags": ["ambient", "focus", "chill", "study", "calm"]},
{"title": "Clair de Lune", "artist": "Claude Debussy", "album": "Suite Bergamasque", "year": 1905,
"tags": ["classical", "chill", "focus", "melancholic", "study"]},
{"title": "River Flows in You","artist": "Yiruma", "album": "First Love", "year": 2001,
"tags": ["classical", "chill", "romantic", "focus", "instrumental"]},
{"title": "Take Five", "artist": "Dave Brubeck", "album": "Time Out", "year": 1959,
"tags": ["jazz", "chill", "focus", "study", "instrumental"]},
{"title": "Lofi Beats Vol 1", "artist": "Lofi Girl", "album": "Study Mix", "year": 2022,
"tags": ["lofi", "focus", "chill", "study", "instrumental"]},
# Workout / motivation
{"title": "Eye of the Tiger", "artist": "Survivor", "album": "Eye of the Tiger", "year": 1982,
"tags": ["rock", "workout", "energetic", "gym", "motivation"]},
{"title": "Stronger", "artist": "Kanye West", "album": "Graduation", "year": 2007,
"tags": ["hiphop", "workout", "energetic", "gym", "motivation"]},
{"title": "Lose Yourself", "artist": "Eminem", "album": "8 Mile", "year": 2002,
"tags": ["hiphop", "workout", "energetic", "motivation", "gym"]},
# Indie / alternative
{"title": "Heat Waves", "artist": "Glass Animals", "album": "Dreamland", "year": 2020,
"tags": ["indie", "chill", "melancholic", "romantic", "driving"]},
# Classic rock / jazz
{"title": "Bohemian Rhapsody","artist": "Queen", "album": "A Night at the Opera", "year": 1975,
"tags": ["rock", "classic", "energetic", "dramatic", "nostalgic"]},
{"title": "Fly Me to the Moon","artist": "Frank Sinatra", "album": "It Might as Well Be Swing", "year": 1964,
"tags": ["jazz", "romantic", "chill", "classic", "dinner"]},
{"title": "Hotel California", "artist": "Eagles", "album": "Hotel California", "year": 1976,
"tags": ["rock", "classic", "nostalgic", "driving", "melancholic"]},
]
def seed():
init_db()
db = SessionLocal()
try:
# Wipe existing data so the script is idempotent (safe to re-run).
deleted = db.query(Song).delete()
logger.info("Cleared %d existing songs", deleted)
# PYTHON BASIC: building a list of model instances from dicts.
songs = [Song(**data) for data in SONGS]
db.add_all(songs)
db.commit()
logger.info("Seeded %d songs", len(songs))
finally:
db.close()
if __name__ == "__main__":
seed()Run it:
python seed.pyYou should see Seeded 25 songs. A songs.db file appeared in your project root.
Now go back to http://127.0.0.1:8000/docs, hit GET /songs, click "Try it out", click "Execute". You'll see your 25 songs come back as JSON.
Try these next, in the docs:
GET /songs/random?tag=focus→ gets you a focus songGET /songs/search?tags=workout&tags=hiphop&match=all→ workout AND hiphop songsGET /tags→ all unique tagsGET /metrics→ look at your request counts
This is your API working. Take a second to appreciate it.
In the dad's analogy from the flight: now you have a kitchen. Let's walk in as a customer. Create a quick file called client_demo.py at the project root (don't commit it, it's just to play):
"""client_demo.py — demonstrate calling our own API from a Python script."""
import httpx
BASE = "http://127.0.0.1:8000"
# Get a random focus song
r = httpx.get(f"{BASE}/songs/random", params={"tag": "focus"})
print("Random focus song:", r.json())
# Add a new song
new = {
"title": "Bad Habit",
"artist": "Steve Lacy",
"album": "Gemini Rights",
"year": 2022,
"tags": ["rnb", "chill", "indie", "romantic", "mellow"],
}
r = httpx.post(f"{BASE}/songs", json=new)
print("Created:", r.status_code, r.json())Run it (while the server is still running in another terminal):
python client_demo.pyYou called your own API. You are now both the kitchen and the customer.
Real tests for a real API. FastAPI's TestClient lets you call your endpoints in-process — no need for the server to be running.
"""
tests/test_api.py — API tests using FastAPI's TestClient.
Each test gets a fresh in-memory database so tests don't pollute each other.
This is a key SWE skill: ISOLATING tests so they're deterministic.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.database import get_db
from app.models import Base, Song # Song import registers the model with Base
# ─── Test fixture: a fresh in-memory DB for each test ───────────────────────
@pytest.fixture
def client():
# Use an in-memory SQLite DB. Wiped every test. Lightning fast.
# StaticPool forces all connections to share one DB — without it,
# each connection gets its own empty in-memory DB and create_all
# tables won't be visible to test sessions.
test_engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestSession = sessionmaker(bind=test_engine)
Base.metadata.create_all(bind=test_engine)
def override_get_db():
db = TestSession()
try:
yield db
finally:
db.close()
# Replace the production DB dependency with the test one.
# This is FastAPI's dependency override — clean and powerful.
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
# ─── Helpers ────────────────────────────────────────────────────────────────
def make_song(title="Test Song", tags=None):
return {
"title": title,
"artist": "Test Artist",
"album": "Test Album",
"year": 2024,
"tags": tags or ["test", "happy", "chill", "demo", "pop"],
}
# ─── Tests ──────────────────────────────────────────────────────────────────
def test_health_check(client):
r = client.get("/")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_list_songs_empty(client):
r = client.get("/songs")
assert r.status_code == 200
assert r.json() == []
def test_create_song(client):
r = client.post("/songs", json=make_song("Created Song"))
assert r.status_code == 201
body = r.json()
assert body["title"] == "Created Song"
assert "id" in body
assert "created_at" in body
def test_create_song_validation_missing_title(client):
bad = make_song()
del bad["title"]
r = client.post("/songs", json=bad)
assert r.status_code == 422 # FastAPI's validation error code
def test_create_song_validation_no_tags(client):
bad = make_song()
bad["tags"] = []
r = client.post("/songs", json=bad)
assert r.status_code == 422
def test_get_song(client):
created = client.post("/songs", json=make_song("Findable")).json()
r = client.get(f"/songs/{created['id']}")
assert r.status_code == 200
assert r.json()["title"] == "Findable"
def test_get_song_404(client):
r = client.get("/songs/9999")
assert r.status_code == 404
assert "not found" in r.json()["detail"].lower()
def test_update_song(client):
created = client.post("/songs", json=make_song("Old")).json()
updated = make_song("New")
r = client.put(f"/songs/{created['id']}", json=updated)
assert r.status_code == 200
assert r.json()["title"] == "New"
def test_delete_song(client):
created = client.post("/songs", json=make_song("To Delete")).json()
r = client.delete(f"/songs/{created['id']}")
assert r.status_code == 204
# Confirm it's gone
r = client.get(f"/songs/{created['id']}")
assert r.status_code == 404
def test_filter_by_tag(client):
client.post("/songs", json=make_song("A", tags=["kpop", "happy", "dance", "pop", "energetic"]))
client.post("/songs", json=make_song("B", tags=["rock", "sad", "ballad", "slow", "melancholic"]))
r = client.get("/songs", params={"tag": "kpop"})
assert r.status_code == 200
titles = [s["title"] for s in r.json()]
assert "A" in titles
assert "B" not in titles
def test_search_match_all(client):
client.post("/songs", json=make_song("Both", tags=["happy", "kpop", "dance", "pop", "fun"]))
client.post("/songs", json=make_song("OnlyHappy", tags=["happy", "indie", "chill", "pop", "summer"]))
r = client.get("/songs/search", params={"tags": ["happy", "kpop"], "match": "all"})
titles = [s["title"] for s in r.json()]
assert titles == ["Both"]
def test_search_match_any(client):
client.post("/songs", json=make_song("Kpop", tags=["kpop", "happy", "dance", "pop", "fun"]))
client.post("/songs", json=make_song("Rock", tags=["rock", "sad", "ballad", "slow", "mood"]))
r = client.get("/songs/search", params={"tags": ["kpop", "rock"], "match": "any"})
titles = sorted(s["title"] for s in r.json())
assert titles == ["Kpop", "Rock"]
def test_random_song(client):
client.post("/songs", json=make_song("OnlyFocus", tags=["focus", "lofi", "chill", "study", "instrumental"]))
r = client.get("/songs/random", params={"tag": "focus"})
assert r.status_code == 200
assert "focus" in r.json()["tags"]
def test_random_song_no_match(client):
r = client.get("/songs/random", params={"tag": "nonexistent"})
assert r.status_code == 404
def test_list_tags(client):
client.post("/songs", json=make_song("A", tags=["a", "b", "c", "d", "e"]))
client.post("/songs", json=make_song("B", tags=["c", "d", "e", "f", "g"]))
r = client.get("/tags")
assert r.status_code == 200
assert r.json() == ["a", "b", "c", "d", "e", "f", "g"]
def test_metrics_endpoint(client):
client.get("/songs")
client.get("/songs")
client.get("/songs/9999") # generates a 404
r = client.get("/metrics")
assert r.status_code == 200
body = r.json()
assert body["requests_total"] >= 3
assert body["errors_total"] >= 1Run them:
pytest -vYou should see 16 tests, all green. If any fail, read the output and fix before continuing.
Some things to notice in these tests:
- Each test gets a fresh DB. No pollution between tests. This is the gold standard.
- The
dependency_overridestrick is FastAPI gold. It lets you swap out any dependency for tests. StaticPoolis required for in-memory SQLite. Without it, each connection gets its own empty database andcreate_alltables aren't visible to test sessions. File-based SQLite doesn't need this.- We test both the happy path AND the error path. A test suite that only checks happy paths is barely a test suite.
# Song Library API
A personal song library REST API. Stores songs with tags, supports full CRUD,
and lets you search by mood. Built with FastAPI + SQLAlchemy + SQLite.
## What's in the box
- 9 REST endpoints with full request/response validation
- 25 pre-seeded songs spanning k-pop, pop, focus, workout, jazz, classical, and more
- Tag-based search (AND or OR)
- A "random song for this mood" endpoint
- Auto-generated interactive API docs at `/docs`
- Per-endpoint logging and 4 observability metrics
- 16 automated tests with isolated in-memory databases
## Setup
```bash
git clone https://github.com/<your-username>/song-library-api.git
cd song-library-api
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python seed.py # populate the DB
```
## Run
```bash
uvicorn app.main:app --reload
```
The API is now at `http://127.0.0.1:8000`.
Interactive docs at `http://127.0.0.1:8000/docs`.
## Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Health check |
| GET | `/songs` | List songs (optional `?tag=` filter) |
| GET | `/songs/search?tags=x&tags=y&match=all` | Search by multiple tags |
| GET | `/songs/random?tag=focus` | Random song matching a tag |
| GET | `/songs/{id}` | Get one song |
| POST | `/songs` | Create a song |
| PUT | `/songs/{id}` | Update a song |
| DELETE | `/songs/{id}` | Delete a song |
| GET | `/tags` | List all unique tags |
| GET | `/metrics` | Observability metrics |
## Tests
```bash
pytest -v
```
16 tests covering happy paths, error paths, and validation rules.
## Architecture
```
app/
├── main.py FastAPI app and route handlers
├── database.py SQLAlchemy engine and session factory
├── models.py ORM models (the `songs` table)
├── schemas.py Pydantic request/response models
└── metrics.py In-memory observability counters
```
**Why two model systems (SQLAlchemy + Pydantic)?**
SQLAlchemy models describe what's *in the database*. Pydantic schemas describe
what's *over the wire*. Keeping them separate lets us validate inputs, omit
fields from responses (e.g., never expose internal flags), and version the
external API without changing the DB.
**Tags as JSON:** tags are stored as a JSON array on each song. For our scale
this is simpler than a proper many-to-many table. At higher scale you'd want
a `tags` table and a `song_tags` join table for indexed tag queries.
## License
MITvenv/
__pycache__/
*.pyc
.pytest_cache/
.DS_Store
.vscode/
songs.db
*.sqlite
*.sqlite3
Note we ignore songs.db — the database is a local artifact, not something to commit. Anyone cloning the repo runs python seed.py to recreate it.
git init
git add .
git commit -m "Initial commit: FastAPI song library with CRUD, search, tests"- Go to github.com/new
- Name it
song-library-api - Don't initialize with anything
- Click Create
git branch -M main
git remote add origin https://github.com/<your-username>/song-library-api.git
git push -u origin mainA real SWE-grade API workflow:
- Translating a vague request into concrete endpoints with a route table
- Separating concerns: routes, schemas, ORM, metrics each in their own file
- Two-tier modeling: SQLAlchemy for the database, Pydantic for the API
- FastAPI dependency injection for DB sessions
- Middleware for cross-cutting concerns (logging + metrics on every request)
- HTTP semantics: 201 for create, 204 for delete, 404 for missing, 422 for validation
- Auto-generated API documentation as a side effect of typed code
- In-process testing with TestClient and dependency overrides
- Test isolation with a fresh in-memory DB per test
- Idempotent seed scripts (re-runnable without breaking state)
- Clean repo hygiene:
.gitignorefor runtime artifacts, requirements pinned
- Add a
played_countfield on songs, and aPOST /songs/{id}/playendpoint that increments it. New feature, no breaking changes — practice the workflow. - Add pagination to
GET /songs:?limit=10&offset=0. This is how real APIs avoid returning 100K rows. - Sort options:
GET /songs?sort=year&order=desc. - Refactor tags into a separate table. This is the "real" relational way. Steep climb in SQLAlchemy but worth it.
- Add a
/songs/statsendpoint that returns counts per tag, per year, per artist. Aggregations are a different muscle than CRUD.