In the previous tutorial, we built a CLI tool with Click and Rich. Now let’s build a REST API — a web API that any frontend, mobile app, or other service can call.
We will use FastAPI, the fastest-growing Python web framework. It has automatic documentation, type checking, and built-in validation. By the end of this tutorial, you will have a working Bookmark Manager API.
Why FastAPI?
- Automatic API docs — Swagger UI at
/docs(free, no setup) - Type-safe — uses Python type hints for validation
- Fast — built on Starlette and Pydantic (one of the fastest Python frameworks)
- Async support — native async/await (from Tutorial #18)
- Pydantic validation — automatic input validation (from Tutorial #10)
Install the dependencies:
pip install fastapi uvicorn sqlalchemy
Project Structure
bookmark-api/
src/
py23_fastapi.py # Models, schemas, routes, app
tests/
test_py23.py # API tests with TestClient
Step 1: Database Models (SQLAlchemy)
We reuse the SQLAlchemy patterns from Tutorial #20:
from sqlalchemy import String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from sqlalchemy.pool import StaticPool
class Base(DeclarativeBase):
pass
class BookmarkDB(Base):
__tablename__ = "bookmarks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
url: Mapped[str] = mapped_column(String(500))
description: Mapped[str] = mapped_column(String(1000), default="")
category: Mapped[str] = mapped_column(String(100), default="general")
created_at: Mapped[str] = mapped_column(String(30), default="")
# Database setup
DATABASE_URL = "sqlite:///./bookmarks.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionFactory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
Step 2: Pydantic Schemas (API Layer)
Schemas define what data the API accepts and returns. This is where FastAPI shines — it validates input automatically:
from pydantic import BaseModel, Field
class BookmarkCreate(BaseModel):
"""What the client sends to create a bookmark."""
title: str = Field(min_length=1, max_length=200)
url: str = Field(min_length=1, max_length=500)
description: str = Field(default="", max_length=1000)
category: str = Field(default="general", max_length=100)
class BookmarkUpdate(BaseModel):
"""What the client sends to update a bookmark (all optional)."""
title: str | None = Field(default=None, min_length=1, max_length=200)
url: str | None = Field(default=None, min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=1000)
category: str | None = Field(default=None, max_length=100)
class BookmarkResponse(BaseModel):
"""What the API returns."""
id: int
title: str
url: str
description: str
category: str
created_at: str
model_config = {"from_attributes": True}
class BookmarkListResponse(BaseModel):
"""Paginated list of bookmarks."""
items: list[BookmarkResponse]
total: int
page: int
per_page: int
Key concepts:
Field(min_length=1)— validation that FastAPI enforces automaticallyBookmarkUpdatehas all optional fields — for partial updatesmodel_config = {"from_attributes": True}— lets Pydantic read SQLAlchemy objects directly
Step 3: Repository Pattern (CRUD)
We put all database operations in a repository class:
from datetime import datetime
from sqlalchemy.orm import Session
class BookmarkRepository:
def __init__(self, session: Session) -> None:
self.session = session
def create(self, data: BookmarkCreate) -> BookmarkDB:
bookmark = BookmarkDB(
title=data.title,
url=data.url,
description=data.description,
category=data.category,
created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
self.session.add(bookmark)
self.session.commit()
self.session.refresh(bookmark)
return bookmark
def get_by_id(self, bookmark_id: int) -> BookmarkDB | None:
return self.session.get(BookmarkDB, bookmark_id)
def get_all(
self, page: int = 1, per_page: int = 20,
category: str | None = None, search: str | None = None,
) -> tuple[list[BookmarkDB], int]:
query = self.session.query(BookmarkDB)
if category:
query = query.filter(BookmarkDB.category == category)
if search:
query = query.filter(BookmarkDB.title.contains(search))
total = query.count()
items = query.offset((page - 1) * per_page).limit(per_page).all()
return items, total
def update(self, bookmark_id: int, data: BookmarkUpdate) -> BookmarkDB | None:
bookmark = self.get_by_id(bookmark_id)
if bookmark is None:
return None
for field_name, value in data.model_dump(exclude_unset=True).items():
if value is not None:
setattr(bookmark, field_name, value)
self.session.commit()
self.session.refresh(bookmark)
return bookmark
def delete(self, bookmark_id: int) -> bool:
bookmark = self.get_by_id(bookmark_id)
if bookmark is None:
return False
self.session.delete(bookmark)
self.session.commit()
return True
Step 4: FastAPI Routes
Now we connect everything with FastAPI routes:
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(
title="Bookmark Manager API",
description="A REST API for managing bookmarks",
version="1.0.0",
)
# CORS — allows frontend apps to call this API
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, replace "*" with your frontend domain
allow_methods=["*"],
allow_headers=["*"],
)
router = APIRouter()
Dependency Injection with Depends()
FastAPI’s Depends() is one of its most powerful features. It creates and cleans up resources automatically:
def get_db():
"""Create a database session for each request."""
db = SessionFactory()
try:
yield db # The request uses this session
finally:
db.close() # Clean up after the request
def get_repo(db: Session = Depends(get_db)) -> BookmarkRepository:
"""Create a repository with the request's database session."""
return BookmarkRepository(db)
Now every route that needs the database just adds repo: BookmarkRepository = Depends(get_repo).
CRUD Routes
@router.post("/bookmarks", response_model=BookmarkResponse, status_code=201)
def create_bookmark(
data: BookmarkCreate,
repo: BookmarkRepository = Depends(get_repo),
):
"""Create a new bookmark."""
return repo.create(data)
@router.get("/bookmarks", response_model=BookmarkListResponse)
def list_bookmarks(
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100),
category: str | None = None,
search: str | None = None,
repo: BookmarkRepository = Depends(get_repo),
):
"""List bookmarks with pagination and filtering."""
items, total = repo.get_all(page=page, per_page=per_page,
category=category, search=search)
return BookmarkListResponse(
items=items, total=total, page=page, per_page=per_page
)
@router.get("/bookmarks/{bookmark_id}", response_model=BookmarkResponse)
def get_bookmark(
bookmark_id: int,
repo: BookmarkRepository = Depends(get_repo),
):
"""Get a single bookmark."""
bookmark = repo.get_by_id(bookmark_id)
if bookmark is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark
@router.put("/bookmarks/{bookmark_id}", response_model=BookmarkResponse)
def update_bookmark(
bookmark_id: int,
data: BookmarkUpdate,
repo: BookmarkRepository = Depends(get_repo),
):
"""Update a bookmark."""
bookmark = repo.update(bookmark_id, data)
if bookmark is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark
@router.delete("/bookmarks/{bookmark_id}", status_code=204)
def delete_bookmark(
bookmark_id: int,
repo: BookmarkRepository = Depends(get_repo),
):
"""Delete a bookmark."""
if not repo.delete(bookmark_id):
raise HTTPException(status_code=404, detail="Bookmark not found")
app.include_router(router)
Key patterns:
response_model=BookmarkResponse— FastAPI serializes the response automaticallyQuery(default=1, ge=1)— validates query parameters (page must be >= 1)HTTPException(status_code=404)— returns proper HTTP error codesstatus_code=201— POST returns 201 Created (not 200 OK)status_code=204— DELETE returns 204 No Content
Step 5: Running the API
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
Run it:
python src/py23_fastapi.py
Open http://localhost:8000/docs to see the automatic Swagger documentation. You can test every endpoint right in the browser.
Testing with curl
# Create a bookmark
curl -X POST http://localhost:8000/bookmarks \
-H "Content-Type: application/json" \
-d '{"title": "Python Docs", "url": "https://docs.python.org", "category": "python"}'
# List all bookmarks
curl http://localhost:8000/bookmarks
# Get a single bookmark
curl http://localhost:8000/bookmarks/1
# Update a bookmark
curl -X PUT http://localhost:8000/bookmarks/1 \
-H "Content-Type: application/json" \
-d '{"title": "Python 3.13 Docs"}'
# Delete a bookmark
curl -X DELETE http://localhost:8000/bookmarks/1
Step 6: App Factory for Testing
For testing, we need a factory function that creates a fresh app with a separate database:
def create_app(database_url: str = DATABASE_URL) -> FastAPI:
"""Create a FastAPI app with the given database URL."""
test_engine = create_engine(
database_url,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestSessionFactory = sessionmaker(
autocommit=False, autoflush=False, bind=test_engine
)
Base.metadata.create_all(bind=test_engine)
test_app = FastAPI(title="Bookmark Manager API", version="1.0.0")
test_app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def get_test_db():
db = TestSessionFactory()
try:
yield db
finally:
db.close()
def get_test_repo(db: Session = Depends(get_test_db)) -> BookmarkRepository:
return BookmarkRepository(db)
# Register routes using the standalone router (not app.router)
test_app.include_router(router)
test_app.dependency_overrides[get_db] = get_test_db
test_app.dependency_overrides[get_repo] = get_test_repo
return test_app
The factory creates a separate database engine and overrides the dependencies. This lets tests use an in-memory SQLite database instead of the production file. In production, replace allow_origins=["*"] with your frontend’s domain (e.g., ["https://myapp.com"]).
Step 7: Testing with pytest
We test the API using FastAPI’s TestClient (which uses httpx under the hood):
import pytest
from fastapi.testclient import TestClient
from src.py23_fastapi import create_app
@pytest.fixture
def client():
app = create_app("sqlite:///:memory:")
return TestClient(app)
class TestAPI:
def test_create_bookmark(self, client):
response = client.post("/bookmarks", json={
"title": "Python Docs",
"url": "https://docs.python.org",
})
assert response.status_code == 201
assert response.json()["title"] == "Python Docs"
def test_create_invalid(self, client):
response = client.post("/bookmarks", json={"title": "", "url": ""})
assert response.status_code == 422 # Validation error
def test_get_not_found(self, client):
response = client.get("/bookmarks/999")
assert response.status_code == 404
def test_full_crud(self, client):
# Create
r = client.post("/bookmarks", json={
"title": "Test", "url": "https://test.com"
})
bm_id = r.json()["id"]
# Read
r = client.get(f"/bookmarks/{bm_id}")
assert r.json()["title"] == "Test"
# Update
r = client.put(f"/bookmarks/{bm_id}", json={"title": "Updated"})
assert r.json()["title"] == "Updated"
# Delete
r = client.delete(f"/bookmarks/{bm_id}")
assert r.status_code == 204
# Verify deleted
r = client.get(f"/bookmarks/{bm_id}")
assert r.status_code == 404
Run the tests:
python -m pytest tests/test_py23.py -v
Common Mistakes
1. Not Handling 404 Errors
# BAD — returns None which becomes null JSON
@app.get("/bookmarks/{id}")
def get_bookmark(id: int):
return repo.get_by_id(id) # None if not found!
# GOOD — return proper 404
@app.get("/bookmarks/{id}")
def get_bookmark(id: int):
bookmark = repo.get_by_id(id)
if bookmark is None:
raise HTTPException(status_code=404, detail="Not found")
return bookmark
2. Returning Database Objects Directly
# BAD — exposes internal database structure
@app.get("/bookmarks")
def list_bookmarks():
return session.query(BookmarkDB).all()
# GOOD — use response_model to control the output
@app.get("/bookmarks", response_model=list[BookmarkResponse])
def list_bookmarks():
return session.query(BookmarkDB).all()
3. No Input Validation
# BAD — accepts any string
class BookmarkCreate(BaseModel):
title: str
url: str
# GOOD — validates length and format
class BookmarkCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
url: str = Field(min_length=1, max_length=500)
Source Code
You can find all the code from this tutorial on GitHub:
GitHub: python-tutorial/tutorial-23-fastapi
What’s Next?
In the next tutorial, we will build a web scraper with BeautifulSoup and httpx — fetching web pages, extracting data, and saving results to JSON and CSV.
Related Articles
- Python Tutorial #20: Databases — SQLAlchemy fundamentals
- Python Tutorial #18: Async/Await — async support in FastAPI
- Python Tutorial #10: Dataclasses and Pydantic — Pydantic validation
- Python Tutorial #19: HTTP and APIs — calling APIs (the client side)