Python API with FastAPI
🟠Intermediate Estimated duration: 45 minutes
This tutorial shows you how to build a professional REST API in Python with FastAPI, following the TDD workflow of the Claude foundation.
Objectives
By the end of this tutorial, you will know how to:
- Use
/dev:dev-apito create FastAPI endpoints - Use
/dev:dev-tddfor the Red-Green-Refactor cycle with pytest - Use
/doc:doc-api-specto leverage the auto-generated OpenAPI documentation - Use
/qa:qa-securityto audit your API's security
Prerequisites
- Claude Code installed and foundation configured
- Python 3.11+ installed
uv(recommended) orpipfor dependency management- Basic knowledge of Python and REST
Context
We will create an API for managing tasks (todos) with:
- Full CRUD (Create, Read, Update, Delete)
- Data validation with Pydantic
- OpenAPI documentation auto-generated by FastAPI
- Integration tests with httpx
Phase 1: Project initialization
Create the Python project and configure the initial structure.
# Initialize with uv
uv init api-todos
cd api-todos
# Add dependencies
uv add fastapi uvicorn[standard] pydantic
uv add --dev pytest pytest-asyncio httpx
Target structure after initialization:
api-todos/
├── src/
│ ├── __init__.py
│ ├── main.py # FastAPI application
│ ├── models/ # SQLAlchemy models (if database)
│ ├── schemas/ # Pydantic schemas
│ ├── routers/ # Endpoints by domain
│ └── services/ # Business logic
├── tests/
│ ├── __init__.py
│ └── conftest.py # pytest fixtures
├── pyproject.toml
└── CLAUDE.md
Create a minimal CLAUDE.md to guide Claude on this project:
# api-todos
REST API for task management with FastAPI.
## Stack
- Python 3.11+, FastAPI, Pydantic v2
- Tests: pytest + httpx (AsyncClient)
- Style: PEP 8, mandatory type hints
## Conventions
- Schemas in src/schemas/
- Business logic in src/services/
- Routes in src/routers/
Phase 2: Exploration and planning
Before writing a single line of code, explore and plan.
Explore the structure
/work:work-explore "Analyze the FastAPI project structure and identify the patterns to follow"
Claude will examine:
- The
pyproject.tomland the available dependencies - The naming conventions in place
- Existing configuration files
- The organization of modules
Plan the API
/work:work-plan "CRUD todos API with FastAPI, Pydantic schemas, and httpx tests"
Expected plan:
## Plan: Todos API
### Endpoints to create
- GET /api/todos - List of tasks (with optional filters)
- GET /api/todos/{id} - Task detail
- POST /api/todos - Create a task
- PUT /api/todos/{id} - Update a task
- DELETE /api/todos/{id} - Delete a task
### Files to create
- src/schemas/todo.py - Pydantic schemas (request/response)
- src/services/todo.py - Business logic (in-memory storage)
- src/routers/todo.py - FastAPI routes
- src/main.py - Application and configuration
- tests/test_todos.py - Integration tests
### Identified risks
- ID handling (uuid vs sequential int)
- Thread-safety of in-memory storage
- Pydantic v2 validation (different syntax from v1)
Validate the plan before continuing.
Phase 3: TDD - Pydantic models and schemas
Start with the schemas: they define the API contract.
/dev:dev-tdd "Pydantic schemas for tasks: TodoCreate, TodoUpdate, TodoResponse"
Red-Green-Refactor cycle
1. Red - The failing test
# tests/test_schemas.py
import pytest
from pydantic import ValidationError
from src.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
class TestTodoCreate:
def test_valid_todo(self):
todo = TodoCreate(title="Learn FastAPI")
assert todo.title == "Learn FastAPI"
assert todo.description is None
assert todo.completed is False
def test_title_required(self):
with pytest.raises(ValidationError) as exc_info:
TodoCreate()
errors = exc_info.value.errors()
assert any(e["loc"] == ("title",) for e in errors)
def test_title_cannot_be_empty(self):
with pytest.raises(ValidationError):
TodoCreate(title="")
def test_title_max_length(self):
with pytest.raises(ValidationError):
TodoCreate(title="x" * 201)
$ pytest tests/test_schemas.py
FAILED - ModuleNotFoundError: No module named 'src.schemas.todo'
2. Green - Minimal implementation
# src/schemas/todo.py
from pydantic import BaseModel, Field
from datetime import datetime
from uuid import UUID
class TodoCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
completed: bool = False
class TodoUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=200)
description: str | None = None
completed: bool | None = None
class TodoResponse(BaseModel):
id: UUID
title: str
description: str | None
completed: bool
created_at: datetime
$ pytest tests/test_schemas.py
4 passed in 0.12s
3. Refactor - Improvements
Claude suggests adding a business validator and an OpenAPI example:
class TodoCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: str | None = Field(None, max_length=1000)
completed: bool = False
model_config = {
"json_schema_extra": {
"example": {
"title": "Read the FastAPI documentation",
"description": "Chapters on dependencies and security",
"completed": False,
}
}
}
Commit this step:
/work:work-commit
test(schemas): add Pydantic schema tests for TodoCreate/Update/Response
feat(schemas): implement Pydantic v2 schemas with validation rules
Phase 4: TDD - CRUD endpoints
Now move on to the FastAPI routes, tested via httpx.AsyncClient.
/dev:dev-tdd "FastAPI CRUD endpoints for tasks with httpx tests"
Test fixture
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from src.main import app
@pytest.fixture
async def client():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
Tests - Creation and validation
1. Red
# tests/test_todos.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
class TestCreateTodo:
async def test_create_todo_returns_201(self, client: AsyncClient):
response = await client.post(
"/api/todos",
json={"title": "Learn TDD", "description": "With pytest and httpx"},
)
assert response.status_code == 201
body = response.json()
assert body["title"] == "Learn TDD"
assert body["completed"] is False
assert "id" in body
assert "created_at" in body
async def test_create_todo_missing_title_returns_422(self, client: AsyncClient):
response = await client.post(
"/api/todos",
json={"description": "No title"},
)
assert response.status_code == 422
detail = response.json()["detail"]
assert any("title" in str(e) for e in detail)
$ pytest tests/test_todos.py
FAILED - 404 Not Found (routes not yet created)
2. Green - Routes and service
Claude creates the in-memory service and then the routes:
# src/services/todo.py
from uuid import uuid4, UUID
from datetime import datetime, timezone
from src.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
class TodoService:
def __init__(self):
self._store: dict[UUID, TodoResponse] = {}
def create(self, data: TodoCreate) -> TodoResponse:
todo = TodoResponse(
id=uuid4(),
title=data.title,
description=data.description,
completed=data.completed,
created_at=datetime.now(timezone.utc),
)
self._store[todo.id] = todo
return todo
def get(self, todo_id: UUID) -> TodoResponse | None:
return self._store.get(todo_id)
def list_all(self, completed: bool | None = None) -> list[TodoResponse]:
todos = list(self._store.values())
if completed is not None:
todos = [t for t in todos if t.completed == completed]
return todos
def update(self, todo_id: UUID, data: TodoUpdate) -> TodoResponse | None:
todo = self._store.get(todo_id)
if not todo:
return None
updated = todo.model_copy(
update={k: v for k, v in data.model_dump().items() if v is not None}
)
self._store[todo_id] = updated
return updated
def delete(self, todo_id: UUID) -> bool:
return self._store.pop(todo_id, None) is not None
todo_service = TodoService()
# src/routers/todo.py
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query
from src.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
from src.services.todo import todo_service
router = APIRouter(prefix="/api/todos", tags=["todos"])
@router.post("", response_model=TodoResponse, status_code=201)
async def create_todo(data: TodoCreate) -> TodoResponse:
return todo_service.create(data)
@router.get("", response_model=list[TodoResponse])
async def list_todos(
completed: bool | None = Query(None, description="Filter by status")
) -> list[TodoResponse]:
return todo_service.list_all(completed=completed)
@router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo(todo_id: UUID) -> TodoResponse:
todo = todo_service.get(todo_id)
if not todo:
raise HTTPException(status_code=404, detail="Task not found")
return todo
@router.put("/{todo_id}", response_model=TodoResponse)
async def update_todo(todo_id: UUID, data: TodoUpdate) -> TodoResponse:
todo = todo_service.update(todo_id, data)
if not todo:
raise HTTPException(status_code=404, detail="Task not found")
return todo
@router.delete("/{todo_id}", status_code=204)
async def delete_todo(todo_id: UUID) -> None:
if not todo_service.delete(todo_id):
raise HTTPException(status_code=404, detail="Task not found")
$ pytest tests/test_todos.py
2 passed in 0.31s
Tests - Error cases (404)
/dev:dev-tdd "test 404 error cases on GET, PUT, and DELETE"
Red
@pytest.mark.asyncio
class TestTodoNotFound:
async def test_get_unknown_id_returns_404(self, client: AsyncClient):
fake_id = "00000000-0000-0000-0000-000000000000"
response = await client.get(f"/api/todos/{fake_id}")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
async def test_update_unknown_id_returns_404(self, client: AsyncClient):
fake_id = "00000000-0000-0000-0000-000000000000"
response = await client.put(
f"/api/todos/{fake_id}", json={"title": "New title"}
)
assert response.status_code == 404
async def test_delete_unknown_id_returns_404(self, client: AsyncClient):
fake_id = "00000000-0000-0000-0000-000000000000"
response = await client.delete(f"/api/todos/{fake_id}")
assert response.status_code == 404
The existing routes already handle these cases. The tests pass immediately.
$ pytest tests/
8 passed in 0.45s
Commit this step:
/work:work-commit
test(api): add CRUD endpoint tests with httpx AsyncClient
feat(api): implement CRUD routes and in-memory TodoService
Phase 5: Documentation and quality
OpenAPI documentation
FastAPI generates the OpenAPI documentation automatically. Use the agent to complete it and export it.
/doc:doc-api-spec
Claude will:
- Verify that each endpoint has a
summaryand adescription - Make sure response codes are documented (
responses) - Add examples to Pydantic schemas if missing
- Export the specification as
openapi.jsonif requested
Abridged output:
doc-api-spec - FastAPI API analysis
Documented endpoints: 5/5
- POST /api/todos [OK] summary, requestBody, 201/422
- GET /api/todos [OK] summary, query params, 200
- GET /api/todos/{id} [OK] summary, path param, 200/404
- PUT /api/todos/{id} [WARN] missing description - auto-added
- DELETE /api/todos/{id} [OK] summary, 204/404
Actions: description added on PUT /api/todos/{id}
Documentation available at http://localhost:8000/docs (Swagger UI)
http://localhost:8000/redoc (ReDoc)
One of FastAPI's advantages: interactive documentation is available without any extra configuration on /docs.
Security audit
/qa:qa-security
Abridged output:
qa-security - FastAPI API security audit
[INFO] Input validation: Pydantic v2 active on all endpoints
[INFO] Error handling: HTTPException used correctly
[WARN] Rate limiting: no middleware detected
-> Recommended: slowapi or custom Starlette middleware
[WARN] Authentication: no protection on endpoints
-> For a production API, add OAuth2 or API key
[INFO] Security headers: add CORSMiddleware if exposed publicly
[INFO] No SQL injection detected (in-memory storage)
Score: 72/100 (acceptable for a prototype, review before production)
P1 items: rate limiting, authentication
P2 items: CORS, security headers
Claude proposes the P1 fixes if you want to apply them:
/qa:qa-loop "score 80"
Phase 6: Commit and Pull Request
Verify the final state of the tests before committing.
pytest --tb=short
tests/test_schemas.py ... 4 passed
tests/test_todos.py ....... 7 passed
--------------------------------------
11 passed in 0.52s
Commit the documentation and audit additions:
/work:work-commit
docs(api): add OpenAPI descriptions and response examples
feat(security): add rate limiting middleware (slowapi)
Create the Pull Request:
/work:work-pr
Generated description:
## Todos API - FastAPI + TDD
### Changes
- Pydantic v2 schemas (TodoCreate, TodoUpdate, TodoResponse)
- In-memory service with full CRUD
- 5 REST endpoints: POST/GET/GET/:id/PUT/:id/DELETE/:id
- 11 integration tests via httpx.AsyncClient
- Enriched OpenAPI documentation (Swagger UI on /docs)
- Rate limiting via slowapi
### Tests
11 passed, 87% coverage
### Security
qa-security score: 82/100
Recap
You have built a complete FastAPI REST API following the TDD workflow of the foundation.
Final structure
api-todos/
├── src/
│ ├── main.py # FastAPI application, router inclusion
│ ├── schemas/
│ │ └── todo.py # Pydantic: TodoCreate, TodoUpdate, TodoResponse
│ ├── services/
│ │ └── todo.py # Business logic, in-memory storage
│ └── routers/
│ └── todo.py # 5 CRUD endpoints
├── tests/
│ ├── conftest.py # httpx.AsyncClient fixture
│ ├── test_schemas.py # Pydantic validation tests
│ └── test_todos.py # endpoint integration tests
└── pyproject.toml
Commands used
| Command | Phase | Result |
|---|---|---|
/work:work-explore | Exploration | Understanding of the structure and conventions |
/work:work-plan | Planning | Validated plan with endpoints, files, and risks |
/dev:dev-tdd | Schemas | Pydantic tests + v2 schemas implementation |
/dev:dev-tdd | Endpoints | httpx tests + CRUD routes + in-memory service |
/doc:doc-api-spec | Documentation | Enriched OpenAPI, working Swagger UI |
/qa:qa-security | Audit | Security score, rate limiting and auth recommendations |
/work:work-commit | Commit | Atomic commits per phase |
/work:work-pr | PR | Pull Request with generated description |
What FastAPI brings compared to Node.js
| Aspect | FastAPI (Python) | Express (Node.js) |
|---|---|---|
| Validation | Pydantic (built-in) | Zod (external dependency) |
| Documentation | Auto-generated OpenAPI | swagger-jsdoc plugin |
| Types | Python type hints | TypeScript |
| Async tests | pytest-asyncio + httpx | supertest |
| Performance | Comparable (ASGI) | Comparable (event loop) |
Going further
- Python Guide - Conventions, async, packaging
- API Guide - REST best practices, versioning, pagination
- Auth Guide - OAuth2, JWT, API keys with FastAPI
- Database Guide - SQLAlchemy, Alembic, migrations
- Tutorial 10: Full project - Capstone integrating all guides
Pydantic v2 is significantly faster than v1 and the syntax has evolved. If you take over an existing project, check the version with uv pip show pydantic. Internal Config schemas are replaced by model_config in v2.
One of FastAPI's main strengths is the automatic generation of Swagger UI (/docs) and ReDoc (/redoc) without any extra configuration. Use /doc:doc-api-spec to enrich the descriptions, not to generate the spec from scratch.