description: "Project — REST API + Database — Keeping schemas.py (API shape) separate from models.py (database shape) is deliberate: it lets the API contract evolve…"---
11 · Project — REST API + Database¶
🎥 Video walkthrough¶
The Level 3 capstone: a complete FastAPI service backed by a real SQLite
database via SQLAlchemy, with full CRUD, Pydantic validation, proper error
handling, and a pytest test suite that exercises the whole stack.
What you'll build¶
A "Book Catalog" API that:
- Persists books to a SQLite database using the SQLAlchemy ORM
- Exposes
POST,GET(list + single),PATCH, andDELETEendpoints - Validates input with Pydantic models, separate from the database models
- Returns proper HTTP status codes and error bodies for missing/invalid data
- Has an isolated, file-independent test suite using an in-memory database
Project layout¶
book_api/
app/
__init__.py
database.py
models.py
schemas.py
crud.py
main.py
tests/
conftest.py
test_books.py
requirements.txt
requirements.txt¶
app/database.py — engine & session setup¶
# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
DATABASE_URL = "sqlite:///./books.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
"""FastAPI dependency: yields a session, always closes it afterward."""
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models.py — the database table¶
# app/models.py
from sqlalchemy import String, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(200), index=True)
author: Mapped[str] = mapped_column(String(200))
year: Mapped[int] = mapped_column(Integer)
read: Mapped[bool] = mapped_column(Boolean, default=False)
app/schemas.py — Pydantic request/response models¶
# app/schemas.py
from pydantic import BaseModel, Field, ConfigDict
class BookCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=200)
year: int = Field(ge=0, le=2100)
read: bool = False
class BookUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
author: str | None = Field(default=None, min_length=1, max_length=200)
year: int | None = Field(default=None, ge=0, le=2100)
read: bool | None = None
class BookOut(BaseModel):
model_config = ConfigDict(from_attributes=True) # allows building this from an ORM object
id: int
title: str
author: str
year: int
read: bool
Keeping schemas.py (API shape) separate from models.py (database shape) is
deliberate: it lets the API contract evolve independently of the storage
layer, and it means internal-only columns never leak into a response by
accident.
app/crud.py — database operations¶
# app/crud.py
from sqlalchemy.orm import Session
from sqlalchemy import select
from . import models, schemas
def create_book(db: Session, book: schemas.BookCreate) -> models.Book:
db_book = models.Book(**book.model_dump())
db.add(db_book)
db.commit()
db.refresh(db_book)
return db_book
def get_book(db: Session, book_id: int) -> models.Book | None:
return db.get(models.Book, book_id)
def list_books(db: Session, read: bool | None = None) -> list[models.Book]:
stmt = select(models.Book)
if read is not None:
stmt = stmt.where(models.Book.read == read)
return list(db.scalars(stmt))
def update_book(db: Session, book_id: int, patch: schemas.BookUpdate) -> models.Book | None:
db_book = get_book(db, book_id)
if db_book is None:
return None
for field, value in patch.model_dump(exclude_unset=True).items():
setattr(db_book, field, value)
db.commit()
db.refresh(db_book)
return db_book
def delete_book(db: Session, book_id: int) -> bool:
db_book = get_book(db, book_id)
if db_book is None:
return False
db.delete(db_book)
db.commit()
return True
exclude_unset=True in update_book means a PATCH only overwrites fields
the client actually sent — None in the request stays untouched rather than
wiping out an existing value.
app/main.py — the FastAPI app¶
# app/main.py
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from . import crud, schemas
from .database import Base, engine, get_db
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Book Catalog API")
@app.post("/books", response_model=schemas.BookOut, status_code=201)
def create_book(book: schemas.BookCreate, db: Session = Depends(get_db)):
return crud.create_book(db, book)
@app.get("/books", response_model=list[schemas.BookOut])
def list_books(read: bool | None = None, db: Session = Depends(get_db)):
return crud.list_books(db, read=read)
@app.get("/books/{book_id}", response_model=schemas.BookOut)
def get_book(book_id: int, db: Session = Depends(get_db)):
book = crud.get_book(db, book_id)
if book is None:
raise HTTPException(status_code=404, detail="book not found")
return book
@app.patch("/books/{book_id}", response_model=schemas.BookOut)
def update_book(book_id: int, patch: schemas.BookUpdate, db: Session = Depends(get_db)):
book = crud.update_book(db, book_id, patch)
if book is None:
raise HTTPException(status_code=404, detail="book not found")
return book
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int, db: Session = Depends(get_db)):
deleted = crud.delete_book(db, book_id)
if not deleted:
raise HTTPException(status_code=404, detail="book not found")
Running it¶
pip install -r requirements.txt
uvicorn app.main:app --reload
# visit http://127.0.0.1:8000/docs for interactive Swagger UI
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Clean Code", "author": "Robert Martin", "year": 2008}'
curl http://127.0.0.1:8000/books
curl http://127.0.0.1:8000/books?read=false
tests/conftest.py — an isolated test database¶
# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from app.database import Base, get_db
from app.main import app
TEST_DATABASE_URL = "sqlite:///:memory:"
@pytest.fixture
def client():
engine = create_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
Overriding the get_db dependency with an in-memory SQLite database means
every test run starts from a clean slate and never touches the real
books.db file.
tests/test_books.py¶
# tests/test_books.py
def test_create_book(client):
response = client.post("/books", json={"title": "Dune", "author": "Frank Herbert", "year": 1965})
assert response.status_code == 201
body = response.json()
assert body["title"] == "Dune"
assert body["read"] is False
assert "id" in body
def test_create_book_rejects_invalid_year(client):
response = client.post("/books", json={"title": "Bad Book", "author": "Nobody", "year": 9999})
assert response.status_code == 422
def test_get_book_not_found(client):
response = client.get("/books/999")
assert response.status_code == 404
def test_list_and_filter_books(client):
client.post("/books", json={"title": "Book A", "author": "X", "year": 2000, "read": True})
client.post("/books", json={"title": "Book B", "author": "Y", "year": 2010, "read": False})
all_books = client.get("/books").json()
assert len(all_books) == 2
unread = client.get("/books?read=false").json()
assert len(unread) == 1
assert unread[0]["title"] == "Book B"
def test_update_book_partial(client):
created = client.post("/books", json={"title": "WIP", "author": "Someone", "year": 2020}).json()
response = client.patch(f"/books/{created['id']}", json={"read": True})
assert response.status_code == 200
updated = response.json()
assert updated["read"] is True
assert updated["title"] == "WIP" # untouched fields stay the same
def test_delete_book(client):
created = client.post("/books", json={"title": "Temp", "author": "Someone", "year": 2020}).json()
delete_response = client.delete(f"/books/{created['id']}")
assert delete_response.status_code == 204
get_response = client.get(f"/books/{created['id']}")
assert get_response.status_code == 404
Running the tests¶
pytest -v tests/
# tests/test_books.py::test_create_book PASSED
# tests/test_books.py::test_create_book_rejects_invalid_year PASSED
# tests/test_books.py::test_get_book_not_found PASSED
# tests/test_books.py::test_list_and_filter_books PASSED
# tests/test_books.py::test_update_book_partial PASSED
# tests/test_books.py::test_delete_book PASSED
How It Actually Works¶
get_db() being a generator function is exactly what makes it work as a FastAPI
dependency with cleanup: FastAPI recognizes that Depends(get_db) wraps a generator,
so instead of calling it once and using the return value, it calls next() on it to
run up to the yield, hands the yielded Session to your route as the dependency
value, and — after the route function fully returns (success or exception) —
resumes the generator past yield to run db.close() inside the finally block.
This is the identical generator-suspend-and-resume mechanism from Level 2's
context managers, applied here to guarantee a Session (and the underlying
database connection it holds) is released after every single request, even one that
raises partway through.
connect_args={"check_same_thread": False} exists because of a genuine mismatch
between two different concurrency models: SQLite's C library, by default, raises an
error if you use a connection from a different OS thread than the one that created
it (its own safety check against cross-thread misuse of a non-thread-safe handle).
FastAPI, run by uvicorn, executes synchronous (non-async def) route functions in
a worker thread pool so they don't block the event loop — meaning a request's
database work can genuinely happen on a different thread than the one that opened
the connection. This flag tells SQLite's driver to skip that check, which is safe
here specifically because SQLAlchemy's sessionmaker hands out one dedicated
connection per request (via get_db) rather than sharing one connection
concurrently across threads.
Separating schemas.py (BookOut) from models.py (Book) is enforced
mechanically by ConfigDict(from_attributes=True): without it, Pydantic's model
constructor only accepts a dict-like mapping of keys to values, but a SQLAlchemy
Book instance is a plain Python object whose data lives in attributes
(book.title), not dict keys. from_attributes=True tells Pydantic's validator to
read each declared field via getattr instead of __getitem__, which is the entire
mechanism letting response_model=BookOut turn an ORM row into a JSON-serializable
model automatically — and it also means only the fields declared on BookOut are
ever read off the ORM object, so a sensitive column added to Book later doesn't
silently start appearing in API responses; it has to be added to BookOut
explicitly first.
tests/conftest.py's isolated test database works by dependency override, not
by re-pointing the real one: FastAPI stores dependencies in a resolvable registry
keyed by the dependency function object itself, and app.dependency_overrides[get_db]
= override_get_db swaps in a different generator function purely for the duration
of the test session — every route that declares Depends(get_db) transparently
receives the overridden version instead, with zero changes to main.py's route
code, because the override lookup happens by function identity at request time, not
by anything baked into the route definitions themselves.
Stretch goals¶
- Add pagination (
?limit=&offset=) toGET /books. - Add a
genrefield with an enum-constrained value in the Pydantic schema. - Package the app (tying back to Packaging & Distribution) with a console script that starts the server.
Completing this project means you're ready for Level 4 · Master.