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 automatically
  • BookmarkUpdate has all optional fields — for partial updates
  • model_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 automatically
  • Query(default=1, ge=1) — validates query parameters (page must be >= 1)
  • HTTPException(status_code=404) — returns proper HTTP error codes
  • status_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.