Aller au contenu principal

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-api to create FastAPI endpoints
  • Use /dev:dev-tdd for the Red-Green-Refactor cycle with pytest
  • Use /doc:doc-api-spec to leverage the auto-generated OpenAPI documentation
  • Use /qa:qa-security to audit your API's security

Prerequisites

  • Claude Code installed and foundation configured
  • Python 3.11+ installed
  • uv (recommended) or pip for 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.toml and 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 summary and a description
  • Make sure response codes are documented (responses)
  • Add examples to Pydantic schemas if missing
  • Export the specification as openapi.json if 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

CommandPhaseResult
/work:work-exploreExplorationUnderstanding of the structure and conventions
/work:work-planPlanningValidated plan with endpoints, files, and risks
/dev:dev-tddSchemasPydantic tests + v2 schemas implementation
/dev:dev-tddEndpointshttpx tests + CRUD routes + in-memory service
/doc:doc-api-specDocumentationEnriched OpenAPI, working Swagger UI
/qa:qa-securityAuditSecurity score, rate limiting and auth recommendations
/work:work-commitCommitAtomic commits per phase
/work:work-prPRPull Request with generated description

What FastAPI brings compared to Node.js

AspectFastAPI (Python)Express (Node.js)
ValidationPydantic (built-in)Zod (external dependency)
DocumentationAuto-generated OpenAPIswagger-jsdoc plugin
TypesPython type hintsTypeScript
Async testspytest-asyncio + httpxsupertest
PerformanceComparable (ASGI)Comparable (event loop)

Going further


Pydantic v2 and FastAPI

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.

Free interactive documentation

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.