Pydantic v2 Capstone: Build a Bookmarks API
Build a complete FastAPI service with Pydantic v2 models, pydantic-settings, validators, and a pytest suite — from empty repo to running…
Pydantic v2 Capstone: Build a Bookmarks API
Build a complete FastAPI service with Pydantic v2 models, pydantic-settings, validators, and a pytest suite — from empty repo to running service.
TL;DR — Key Takeaways
- The split-model pattern (
BookmarkCreate/BookmarkUpdate/BookmarkRead) keeps your input contracts honest and your output shape deliberate — you decide exactly what leaves the service. - A
field_validator(mode='before')onurlnormalizes input before Pydantic coerces it, so every record in the database is consistently formatted. - Tag deduplication belongs in a
field_validator, not amodel_validator—model_validator(mode='after')is for cross-field invariants involving two or more fields. pydantic-settingswithenv_prefixmeans zero config-reading boilerplate and a clear contract for operators deploying the service.- FastAPI dependency injection +
TestClientmakes it trivial to swap the real store for an isolated test database per test — no mocking required. - All five previous parts contribute something here; this is what it looks like when the pieces compose.
On this page
- Why a capstone?
- What we’re building
- Project layout
- Running the service
- Running the tests
- What to do next
- FAQ
Why a capstone?
The previous five parts taught Pydantic in pieces: what a model is, how validators work, how pydantic-settings loads configuration, how FastAPI uses Pydantic under the hood, and how to test and tune it all. What they did not show is how those pieces fit together in a single, coherent service. That is what this part does. We will build a small but realistic Bookmarks REST API — one you could actually ship — and every step will call back to a concept you already know.
What we’re building
A Bookmarks service that lets users:
- Save URLs with a title, optional notes, and up to 10 tags.
- List bookmarks, optionally filtered by tag.
- Update or delete individual bookmarks.
Along the way you will practice:
- Part 2 patterns —
Fieldconstraints,field_validator,model_validator. - Part 3 / 4 patterns —
pydantic-settingswith env-var config, the split-model pattern from the FastAPI integration section. - Part 5 patterns —
model_validate()at the persistence boundary,TestClient-based integration tests, isolated test databases via dependency overrides.
Project layout
bookmarks/
├── pyproject.toml # build system + deps (uv-friendly)
├── README.md # how to run in five lines
├── src/bookmarks/
│ ├── __init__.py # package marker
│ ├── settings.py # BaseSettings — env vars → validated config
│ ├── models.py # BookmarkCreate / BookmarkUpdate / BookmarkRead
│ ├── db.py # thin sqlite3 store; returns plain dicts
│ ├── api.py # FastAPI app, routes, auth dependency
│ └── main.py # uvicorn entry point
└── tests/
├── __init__.py
├── conftest.py # store + TestClient fixtures
└── test_api.py # 11 integration tests
pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "bookmarks"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"pydantic>=2,<3",
"pydantic-settings>=2,<3",
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"httpx>=0.27",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"pytest-anyio>=0.0.0",
"anyio[trio]>=4",
"httpx>=0.27",
]
[tool.hatch.build.targets.wheel]
packages = ["src/bookmarks"]
[tool.pytest.ini_options]
testpaths = ["tests"]
hatchling makes the src/bookmarks package importable from the installed editable wheel. The [dev] extra adds pytest and httpx (which TestClientneeds under the hood). requires-python = ">=3.10" matches the X | Y union syntax used throughout.
settings.py
settings.py is the first file everything else imports. It uses pydantic-settings to load configuration from environment variables, with an optional .envfile for local development.
from functools import lru_cache
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_prefix="BOOKMARKS_",
env_file=".env",
env_file_encoding="utf-8",
)
database_url: str = Field(
default="sqlite:///bookmarks.db",
description="SQLite database file path.",
)
api_token: SecretStr = Field(
default=SecretStr("dev-token"),
description="Bearer token required on write endpoints.",
)
debug: bool = Field(default=False, description="Enable debug mode.")
@lru_cache
def get_settings() -> Settings:
"""Return the application settings, constructed once and cached."""
return Settings()
env_prefix="BOOKMARKS_" means the environment variables are BOOKMARKS_DATABASE_URL, BOOKMARKS_API_TOKEN, and BOOKMARKS_DEBUG — unambiguous and collision-free.
api_token is typed as SecretStr rather than str. SecretStr masks the value in repr(), logs, tracebacks, and model_dump() output, so a bearer token never leaks into your log aggregator — the same pattern taught in Part 3 — custom types. Accessing the raw value requires an explicit .get_secret_value() call, which makes every read-site visible in code review.
get_settings is wrapped with @lru_cache and used as a FastAPI dependency rather than a module-level singleton. The lazy factory pattern is the canonical FastAPI + pydantic-settings idiom: tests can override app.dependency_overrides[get_settings] cleanly, and pytest --collect-onlydoes not open a real database file just by importing the package. extra is not set because the default for BaseSettings is already 'ignore' — the env_prefix is what isolates the config namespace.
Before deploying, set
BOOKMARKS_API_TOKENto a real secret via the environment; thedev-tokendefault is intentional only so the tutorial runs out of the box.
models.py
This is the heart of the Pydantic work. Three models, each with a distinct responsibility.
"""Pydantic models for the Bookmarks API.
Three-model split pattern (see Part 4):
- BookmarkCreate - validated input for POST
- BookmarkUpdate - partial input for PATCH (all fields optional)
- BookmarkRead - outbound shape returned by every endpoint
"""
from datetime import datetime
from urllib.parse import urlparse
from pydantic import (
HttpUrl,
BaseModel,
ConfigDict,
Field,
field_validator,
)
def _normalize_url(raw: str) -> str:
"""Lowercase scheme + host, strip trailing slash from path."""
parsed = urlparse(raw)
normalized = parsed._replace(
scheme=parsed.scheme.lower(),
netloc=parsed.netloc.lower(),
path=parsed.path.rstrip("/") or "/",
)
return normalized.geturl()
def _dedup_tags(tags: list[str]) -> list[str]:
"""Return tags with duplicates removed, preserving first-seen order."""
seen: set[str] = set()
unique: list[str] = []
for tag in tags:
if tag not in seen:
seen.add(tag)
unique.append(tag)
return unique
class BookmarkCreate(BaseModel):
"""Input model for creating a new bookmark."""
url: HttpUrl
title: str = Field(min_length=1, max_length=200)
tags: list[str] = Field(default_factory=list, max_length=10)
notes: str | None = Field(default=None, max_length=1000)
@field_validator("url", mode="before")
@classmethod
def normalize_url(cls, v: object) -> object:
if isinstance(v, str):
return _normalize_url(v)
return v
@field_validator("tags", mode="before")
@classmethod
def normalize_and_dedup_tags(cls, v: object) -> object:
if isinstance(v, list):
cleaned = [t.strip().lower() for t in v if isinstance(t, str) and t.strip()]
return _dedup_tags(cleaned)
return v
class BookmarkUpdate(BaseModel):
"""Partial-update model for PATCH. All fields are optional."""
title: str | None = Field(default=None, min_length=1, max_length=200)
tags: list[str] | None = Field(default=None, max_length=10)
notes: str | None = Field(default=None, max_length=1000)
@field_validator("tags", mode="before")
@classmethod
def normalize_tags(cls, v: object) -> object:
if isinstance(v, list):
return [t.strip().lower() for t in v if isinstance(t, str) and t.strip()]
return v
class BookmarkRead(BaseModel):
"""Output model returned by every endpoint."""
model_config = ConfigDict(from_attributes=True)
id: int
url: HttpUrl
title: str
tags: list[str]
notes: str | None
created_at: datetime
updated_at: datetime
A few things worth pointing out.
normalize_url uses mode='before' because we need to transform the raw string before Pydantic hands it to HttpUrl for structural validation — if we ran after, the value would already be a URL object and the string operations would not apply. This is the mode='before' guidance from Part 5 — pitfall 3.
We use HttpUrl (rejects schemes other than http/https) rather than AnyHttpUrl. AnyHttpUrl accepts local-only forms like http://localhost and http://192.0.2.1 — fine for internal services, but a public bookmarks API should reject them.
normalize_and_dedup_tags handles both normalization and deduplication in a single field_validator(mode='before'). This is the right place for single-field cleanup: the validator runs before max_length=10 is enforced, so a list of duplicates that collapses to fewer than 10 unique tags will not produce a 422. Note that max_length=10 fires before any mode='after' logic, so callers who send 11 distinct tags will still get a 422 — the dedup only helps when duplicates would push a valid-sized list over the limit.
A model_validator(mode='after') is for cross-field invariants involving two or more fields — for example, “if archived_at is set, is_active must be False”. Single-field cleanup like deduplication belongs in the field validator, not the model validator. The same principle is shown in Part 2 — writing validators.
BookmarkRead.url is typed as HttpUrl to preserve the constraint in the OpenAPI schema. Pydantic serializes it as a plain string in the JSON response either way.
Note that notes: str | None = Field(default=None, ...) in BookmarkUpdate means the server cannot distinguish “client omitted notes” from “client sent notes: null to clear it.” Our store treats both as “don’t change.” If you need to support explicit clearing — notes: null means “set to empty” — inspect payload.model_fields_set (a set[str] of fields actually provided in the request) and branch accordingly.
BookmarkRead.model_config = ConfigDict(from_attributes=True) is set in anticipation of the “switch to SQLAlchemy” path described in What to do next. It enables BookmarkRead.model_validate(orm_instance) to work by falling back to attribute access when dict-key access misses. Note: with SQLAlchemy specifically, this follows relationship attributes during validation — serialize an ORM-derived BookmarkRead outside its session and you will get a DetachedInstanceError. Eager-load any relationships you intend to serialize.
db.py
A thin, stdlib-only persistence layer. No ORM — the point of this tutorial is Pydantic, not SQLAlchemy.
"""Thin SQLite persistence layer using Python's stdlib sqlite3.
We deliberately avoid an ORM to keep the tutorial focused on Pydantic.
The store returns plain dicts that `BookmarkRead` can validate via
`model_validate()`.
"""
import json
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Any
_CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
d = dict(row)
d["tags"] = json.loads(d["tags"])
return d
class BookmarkStore:
"""Thread-safe, in-process SQLite store for bookmarks."""
def __init__(self, database_url: str) -> None:
# Strip the "sqlite:///" prefix if present.
db_path = database_url.removeprefix("sqlite:///")
self._db_path = db_path
self._local = threading.local()
self._init_schema()
def _conn(self) -> sqlite3.Connection:
"""Return a per-thread connection (SQLite is not thread-safe)."""
if not hasattr(self._local, "conn"):
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
self._local.conn = conn
return self._local.conn
def _init_schema(self) -> None:
self._conn().execute(_CREATE_TABLE)
self._conn().commit()
# --- write operations ---
def create(self, url: str, title: str, tags: list[str], notes: str | None) -> dict[str, Any]:
now = _now_iso()
cur = self._conn().execute(
"INSERT INTO bookmarks (url, title, tags, notes, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(url, title, json.dumps(tags), notes, now, now),
)
self._conn().commit()
row = self._conn().execute(
"SELECT * FROM bookmarks WHERE id = ?", (cur.lastrowid,)
).fetchone()
return _row_to_dict(row)
def update(
self,
bookmark_id: int,
title: str | None,
tags: list[str] | None,
notes: str | None,
) -> dict[str, Any] | None:
existing = self.get(bookmark_id)
if existing is None:
return None
new_title = title if title is not None else existing["title"]
new_tags = tags if tags is not None else existing["tags"]
new_notes = notes if notes is not None else existing["notes"]
now = _now_iso()
self._conn().execute(
"UPDATE bookmarks SET title=?, tags=?, notes=?, updated_at=? WHERE id=?",
(new_title, json.dumps(new_tags), new_notes, now, bookmark_id),
)
self._conn().commit()
return self.get(bookmark_id)
def delete(self, bookmark_id: int) -> bool:
cur = self._conn().execute(
"DELETE FROM bookmarks WHERE id = ?", (bookmark_id,)
)
self._conn().commit()
return cur.rowcount > 0
# --- read operations ---
def get(self, bookmark_id: int) -> dict[str, Any] | None:
row = self._conn().execute(
"SELECT * FROM bookmarks WHERE id = ?", (bookmark_id,)
).fetchone()
return _row_to_dict(row) if row else None
def list_all(self, tag: str | None = None) -> list[dict[str, Any]]:
if tag:
# SQLite JSON functions not available in all builds; scan instead.
rows = self._conn().execute(
"SELECT * FROM bookmarks ORDER BY created_at DESC"
).fetchall()
result = [_row_to_dict(r) for r in rows]
return [r for r in result if tag.lower() in r["tags"]]
rows = self._conn().execute(
"SELECT * FROM bookmarks ORDER BY created_at DESC"
).fetchall()
return [_row_to_dict(r) for r in rows]
_row_to_dict is the persistence boundary. sqlite3.Row is dict-like but not a dict; converting it here and deserializing tags from JSON means every caller receives a plain dict[str, Any]. BookmarkRead in api.py then validates that dict through FastAPI’s response_model machinery — which is precisely the trust-boundary pattern from Part 4 — database ORM integration. The store does not know about Pydantic; api.py does not know about SQL. The interface between them is a plain dict.
threading.local() gives each OS thread its own SQLite connection. SQLite connections are not thread-safe, so sharing one across Uvicorn worker threads would cause silent data corruption.
api.py
The FastAPI application. Routes are thin: validate input via the Pydantic model in the function signature, call the store, return the raw dict and let response_model handle the validation and serialization on the way out.
"""FastAPI application for the Bookmarks service."""
import hmac
from functools import lru_cache
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
from .db import BookmarkStore
from .models import BookmarkCreate, BookmarkRead, BookmarkUpdate
from .settings import get_settings
app = FastAPI(title="Bookmarks API", version="0.1.0")
@lru_cache
def _get_store() -> BookmarkStore:
"""Lazy store provider - constructed once, cached for the process lifetime."""
return BookmarkStore(get_settings().database_url)
def _require_token(authorization: str | None = Header(default=None)) -> None:
"""Dependency: validate Bearer token on write endpoints."""
expected = f"Bearer {get_settings().api_token.get_secret_value()}"
if not hmac.compare_digest(authorization or "", expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing Authorization header.",
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/bookmarks", response_model=list[BookmarkRead])
def list_bookmarks(
tag: str | None = Query(default=None, description="Filter by tag."),
store: BookmarkStore = Depends(_get_store),
) -> list[dict]:
return store.list_all(tag=tag)
@app.post(
"/bookmarks",
response_model=BookmarkRead,
status_code=status.HTTP_201_CREATED,
)
def create_bookmark(
payload: BookmarkCreate,
store: BookmarkStore = Depends(_get_store),
_auth: None = Depends(_require_token),
) -> dict:
return store.create(
url=str(payload.url),
title=payload.title,
tags=payload.tags,
notes=payload.notes,
)
@app.get("/bookmarks/{bookmark_id}", response_model=BookmarkRead)
def get_bookmark(
bookmark_id: int,
store: BookmarkStore = Depends(_get_store),
) -> dict:
row = store.get(bookmark_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found.")
return row
@app.patch("/bookmarks/{bookmark_id}", response_model=BookmarkRead)
def update_bookmark(
bookmark_id: int,
payload: BookmarkUpdate,
store: BookmarkStore = Depends(_get_store),
_auth: None = Depends(_require_token),
) -> dict:
row = store.update(
bookmark_id=bookmark_id,
title=payload.title,
tags=payload.tags,
notes=payload.notes,
)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found.")
return row
@app.delete("/bookmarks/{bookmark_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_bookmark(
bookmark_id: int,
store: BookmarkStore = Depends(_get_store),
_auth: None = Depends(_require_token),
) -> None:
deleted = store.delete(bookmark_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found.")
_get_store is a dependency function decorated with @lru_cache. The lru_cache means the BookmarkStore is constructed once for the lifetime of the process, but lazily — only when the first request arrives. This keeps pytest --collect-only from opening a real database file just by importing the package, and it makes test isolation clean: app.dependency_overrides[_get_store] = lambda: test_store swaps the real store for an isolated test one without touching any application code.
_require_token calls get_settings().api_token.get_secret_value() to retrieve the token value. The explicit .get_secret_value() call is required because api_token is a SecretStr — a SecretStr cannot be interpolated directly into a string, which forces every access to be intentional and visible in code review.
hmac.compare_digest is used instead of != for the token comparison. String != short-circuits on the first differing byte, enabling a timing side-channel attack; hmac.compare_digest runs in constant time regardless of where the strings differ.
Each route returns the raw dict from the store and declares response_model=BookmarkRead. FastAPI’s response-model machinery does the single validation pass on the way out. We do not also call BookmarkRead.model_validate(row) inside the handler — doing both would validate the same data twice, which is wasted work.
_require_token is declared on write endpoints via _auth: None = Depends(_require_token) — the underscore prefix signals that the return value is intentionally discarded; the function raises on failure and returns nothing on success.
Note str(payload.url) when passing the URL to the store. HttpUrl is a Pydantic URL type, not a plain string — calling str() converts it to its normalized string form before writing to SQLite.
main.py
"""Uvicorn entry point.
Run with:
uvicorn bookmarks.main:app --reload
"""
import uvicorn
from .api import app
from .settings import get_settings
if __name__ == "__main__":
uvicorn.run(
"bookmarks.main:app",
host="0.0.0.0",
port=8000,
reload=get_settings().debug,
)
The reload=get_settings().debug line is a small but useful touch: hot-reload is controlled by the same BOOKMARKS_DEBUG environment variable that operators already know about, rather than a separate flag.
tests/conftest.py
"""Shared pytest fixtures for the Bookmarks API tests."""
import pytest
from fastapi.testclient import TestClient
from bookmarks.api import app, _get_store
from bookmarks.db import BookmarkStore
from bookmarks.settings import get_settings
AUTH_HEADER = {"Authorization": f"Bearer {get_settings().api_token.get_secret_value()}"}
@pytest.fixture()
def store(tmp_path) -> BookmarkStore:
"""A fresh in-memory-ish store backed by a temp SQLite file."""
db_path = str(tmp_path / "test.db")
return BookmarkStore(f"sqlite:///{db_path}")
@pytest.fixture()
def client(store: BookmarkStore) -> TestClient:
"""TestClient wired to a fresh store via dependency override."""
app.dependency_overrides[_get_store] = lambda: store
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
tmp_path is a built-in pytest fixture that provides a fresh temporary directory for each test. Combined with the dependency override, every test function that requests client gets its own isolated SQLite database — no shared state, no ordering dependencies, no teardown logic to write.
app.dependency_overrides[_get_store] = lambda: store is a single, clean line. FastAPI resolves dependency overrides by matching the exact callable registered in Depends(...) — here that callable is _get_store — so keying on _get_store is all that is needed. The app.dependency_overrides.clear() after the yield prevents override leakage between tests.
tests/test_api.py
"""Integration tests for the Bookmarks API.
Each test gets its own isolated SQLite database via the `client` fixture,
so tests are fully independent and can run in any order.
"""
import pytest
from fastapi.testclient import TestClient
from bookmarks.settings import get_settings
AUTH = {"Authorization": f"Bearer {get_settings().api_token.get_secret_value()}"}
BAD_AUTH = {"Authorization": "Bearer wrong-token"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create(client: TestClient, **kwargs) -> dict:
defaults = {"url": "https://example.com/page", "title": "Example", "tags": ["python"]}
defaults.update(kwargs)
resp = client.post("/bookmarks", json=defaults, headers=AUTH)
assert resp.status_code == 201, resp.text
return resp.json()
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
def test_list_empty(client: TestClient) -> None:
resp = client.get("/bookmarks")
assert resp.status_code == 200
assert resp.json() == []
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
def test_create_returns_201(client: TestClient) -> None:
data = _create(client, url="https://docs.pydantic.dev/", title="Pydantic Docs", tags=["pydantic"])
assert data["id"] == 1
assert data["title"] == "Pydantic Docs"
assert "pydantic" in data["tags"]
assert "created_at" in data
def test_create_normalizes_url(client: TestClient) -> None:
data = _create(client, url="HTTPS://Example.COM/path/", title="Normalized")
# scheme and host lowercased; trailing slash stripped from path
assert data["url"].startswith("https://example.com/path")
assert not data["url"].endswith("//")
def test_create_deduplicates_tags(client: TestClient) -> None:
data = _create(client, tags=["python", "Python", "PYTHON"])
assert data["tags"] == ["python"]
def test_create_requires_auth(client: TestClient) -> None:
resp = client.post(
"/bookmarks",
json={"url": "https://example.com", "title": "No auth"},
headers=BAD_AUTH,
)
assert resp.status_code == 401
def test_create_rejects_too_many_tags(client: TestClient) -> None:
tags = [f"tag{i}" for i in range(11)]
resp = client.post(
"/bookmarks",
json={"url": "https://example.com", "title": "Too many tags", "tags": tags},
headers=AUTH,
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Get
# ---------------------------------------------------------------------------
def test_get_existing(client: TestClient) -> None:
created = _create(client, title="Get Me")
resp = client.get(f"/bookmarks/{created['id']}")
assert resp.status_code == 200
assert resp.json()["title"] == "Get Me"
def test_get_missing_returns_404(client: TestClient) -> None:
resp = client.get("/bookmarks/999")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Update
# ---------------------------------------------------------------------------
def test_patch_updates_title(client: TestClient) -> None:
created = _create(client, title="Original")
resp = client.patch(
f"/bookmarks/{created['id']}",
json={"title": "Updated"},
headers=AUTH,
)
assert resp.status_code == 200
assert resp.json()["title"] == "Updated"
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def test_delete_removes_bookmark(client: TestClient) -> None:
created = _create(client)
resp = client.delete(f"/bookmarks/{created['id']}", headers=AUTH)
assert resp.status_code == 204
assert client.get(f"/bookmarks/{created['id']}").status_code == 404
# ---------------------------------------------------------------------------
# Filter by tag
# ---------------------------------------------------------------------------
def test_filter_by_tag(client: TestClient) -> None:
_create(client, title="Python post", tags=["python"])
_create(client, title="Rust post", tags=["rust"])
resp = client.get("/bookmarks?tag=python")
assert resp.status_code == 200
results = resp.json()
assert len(results) == 1
assert results[0]["title"] == "Python post"
The tests hit the full HTTP stack — routing, validation, serialization — not just the model layer. test_create_normalizes_url and test_create_deduplicates_tags verify that the Pydantic validators actually fire end-to-end, not just in isolation. test_create_rejects_too_many_tags confirms that Field(max_length=10) on a list[str] translates to a 422 response. This is the integration-testing approach from Part 5 — testing Pydantic models.
Running the service
# Create and activate a virtual environment
uv venv && source .venv/bin/activate
# Install the app and dev dependencies
uv pip install -e ".[dev]"
# Start the server with hot reload
uvicorn bookmarks.main:app --reload
Create your first bookmark:
curl -X POST http://localhost:8000/bookmarks \
-H "Authorization: Bearer dev-token" \
-H "Content-Type: application/json" \
-d '{
"url": "HTTPS://Docs.Pydantic.Dev/latest/",
"title": "Pydantic v2 docs",
"tags": ["pydantic", "Pydantic", "python"]
}'
The response will show the URL normalized to https://docs.pydantic.dev/latest and the tags deduplicated to ["pydantic", "python"]. The interactive API docs are at [http://localhost:8000/docs.](http://localhost:8000/docs.)
Running the tests
pytest -v
Expected output:
tests/test_api.py::test_list_empty PASSED
tests/test_api.py::test_create_returns_201 PASSED
tests/test_api.py::test_create_normalizes_url PASSED
tests/test_api.py::test_create_deduplicates_tags PASSED
tests/test_api.py::test_create_requires_auth PASSED
tests/test_api.py::test_create_rejects_too_many_tags PASSED
tests/test_api.py::test_get_existing PASSED
tests/test_api.py::test_get_missing_returns_404 PASSED
tests/test_api.py::test_patch_updates_title PASSED
tests/test_api.py::test_delete_removes_bookmark PASSED
tests/test_api.py::test_filter_by_tag PASSED
11 passed in 0.06s
What to do next
The service is intentionally minimal. Here are six directions worth exploring:
- Add JWT authentication. Replace the static bearer token with a proper JWT flow using python-jose or authlib. The
_require_tokendependency is the right place to decode and verify the token; start by changing its signature to return the decoded claims rather thanNone. - Switch to PostgreSQL. Replace
BookmarkStorewith an async SQLAlchemy session;BookmarkRead.model_config = ConfigDict(from_attributes=True)is already set, soBookmarkRead.model_validate(orm_instance)will work without changes toapi.py. - Add pagination. Wrap
list[BookmarkRead]in a genericPage[T]response model — the generic model pattern from Part 3 — generic models applies directly. - Schema versioning. Pin
BookmarkRead.model_json_schema(mode='serialization')output in a test — that is the schema clients actually receive — and diff it in CI to catch accidental breaking changes before they reach clients. - Rate limiting. Add slowapi as a middleware; no Pydantic changes needed, the existing dependency chain composes cleanly.
- Async endpoints. Change
deftoasync defon the route functions and swapBookmarkStorefor an async variant usingaiosqlite.TestClientruns async handlers, but if your store interface itself becomes async you will need to update the fixtures accordingly.
FAQ
Why SQLite and not PostgreSQL?
SQLite ships with Python, requires no server process, and is sufficient for any single-process service. The goal here is showing how Pydantic fits into a service, not how to operate a database. Swapping in PostgreSQL means replacing BookmarkStore — the rest of the code does not need to change because the store’s public interface returns plain dicts.
Why no ORM?
For the same reason: adding SQLAlchemy would require explaining its session lifecycle, relationship loading, and DeclarativeBase in an article that is really about Pydantic. The stdlib sqlite3 module is readable by anyone who knows Python. If you want to see the ORM integration pattern in detail, Part 4 — database ORM integration covers it with from_attributes=True and the SQLAlchemy bridge.
How do I add user accounts so each user sees only their own bookmarks?
Add a user_id: int column to the bookmarks table, a User model and auth flow (JWT is the usual choice with FastAPI), and filter list_all by the authenticated user’s ID. The _require_token dependency is the right place to extract the user identity and pass it down to the store.
Where do database migrations fit?
Alembic is the standard tool for SQLite and PostgreSQL migrations. When you move to SQLAlchemy, add Alembic alongside it: alembic init migrations, define your env.py to point at the same DATABASE_URL from settings.py, and run alembic upgrade head on startup or as a separate step in your deployment pipeline. If you are sticking with sqlite3 for a small project, hand-written ALTER TABLE statements in an upgrade script are often sufficient until you need the full Alembic machinery.
Previous in the series
- Part 5 — Pydantic v2 Best Practices, Performance, and Migration
- Part 4 — Pydantic v2 in the Real World: FastAPI, Settings, Pipelines
- Part 3 — Pydantic v2 Advanced Features and Custom Types
- Part 2 — Pydantic v2 Core Tutorial: Models, Fields, Validators
- Part 1 — Pydantic v2: A Practical Introduction for Python Developers
This is the final part in the six-part series on Pydantic v2. Thank you for following along.
메타데이터
- post_id
- 6f3f5d73f11b
- slug
- pydantic-v2-capstone-build-a-bookmarks-api-6f3f5d73f11b
- url
- https://medium.com/@ez7/pydantic-v2-capstone-build-a-bookmarks-api-6f3f5d73f11b
- canonical_url
- https://medium.com/@ez7/pydantic-v2-capstone-build-a-bookmarks-api-6f3f5d73f11b
- author_url
- https://medium.com/@ez7
- status
- ok
- fetched_at
- 2026-07-07 15:26:23