davila7--claude-code-templates
457 行
15 KiB
Markdown
457 行
15 KiB
Markdown
---
|
|
name: fastapi-endpoint
|
|
description: Plan and build production-ready FastAPI endpoints with async SQLAlchemy, Pydantic v2 models, dependency injection for auth, and pytest tests. Uses interview-driven planning to clarify data models, authentication method, pagination strategy, and caching before writing any code.
|
|
tags: [fastapi, python, api, async, pydantic, sqlalchemy, backend]
|
|
---
|
|
|
|
# FastAPI Endpoint Builder
|
|
|
|
## When to use
|
|
|
|
Use this skill when you need to:
|
|
|
|
- Add new API endpoints to an existing FastAPI project
|
|
- Build CRUD operations with proper validation and error handling
|
|
- Set up authenticated endpoints with dependency injection
|
|
- Create async database queries with SQLAlchemy 2.0
|
|
- Generate complete test coverage for API routes
|
|
|
|
## Phase 1: Explore (Plan Mode)
|
|
|
|
Enter plan mode. Before writing any code, explore the existing project to understand:
|
|
|
|
### Project structure
|
|
- Find the FastAPI app entry point (`main.py`, `app.py`, or `app/__init__.py`)
|
|
- Identify the router organization pattern (single file vs `routers/` directory)
|
|
- Check for existing `models/`, `schemas/`, `crud/`, or `services/` directories
|
|
- Look at `pyproject.toml` or `requirements.txt` for installed dependencies
|
|
|
|
### Existing patterns
|
|
- How are existing endpoints structured? (function-based vs class-based)
|
|
- What ORM is used? (SQLAlchemy 2.0 async, Tortoise, raw SQL, none)
|
|
- How is the database session managed? (`Depends(get_db)`, middleware, other)
|
|
- What auth pattern exists? (OAuth2PasswordBearer, API key header, custom)
|
|
- Are there existing Pydantic base models or shared schemas?
|
|
- What response format is standard? (direct model, wrapped `{"data": ..., "meta": ...}`)
|
|
|
|
### Test patterns
|
|
- Where do tests live? (`tests/`, `test_*.py`, `*_test.py`)
|
|
- What test client is used? (httpx AsyncClient, TestClient, pytest-asyncio)
|
|
- Are there test fixtures for database and auth?
|
|
|
|
## Phase 2: Interview (AskUserQuestion)
|
|
|
|
Use AskUserQuestion to clarify requirements. Ask in rounds — do NOT dump all questions at once.
|
|
|
|
### Round 1: Core endpoint
|
|
|
|
```
|
|
Question: "What resource does this endpoint manage?"
|
|
Header: "Resource"
|
|
Options:
|
|
- "New resource (I'll describe the fields)" — Creating a new data model from scratch
|
|
- "Existing model (extend it)" — Adding endpoints for a model that already exists in the codebase
|
|
- "Relationship endpoint (nested)" — e.g., /users/{id}/orders — endpoint on a related resource
|
|
|
|
Question: "Which HTTP methods do you need?"
|
|
Header: "Methods"
|
|
multiSelect: true
|
|
Options:
|
|
- "Full CRUD (GET list, GET detail, POST, PUT/PATCH, DELETE)" — All standard operations
|
|
- "Read-only (GET list + GET detail)" — No mutations
|
|
- "Custom action (POST /resource/{id}/action)" — Business logic endpoint, not standard CRUD
|
|
```
|
|
|
|
### Round 2: Data model (if new resource)
|
|
|
|
```
|
|
Question: "What fields does the resource have? (describe briefly)"
|
|
Header: "Fields"
|
|
Options:
|
|
- "Simple (< 6 fields, basic types)" — Strings, ints, booleans, dates
|
|
- "Medium (6-15 fields, some relations)" — Includes foreign keys or enums
|
|
- "Complex (nested objects, polymorphic)" — JSON fields, discriminated unions, computed fields
|
|
```
|
|
|
|
### Round 3: Auth and access control
|
|
|
|
```
|
|
Question: "How should this endpoint be authenticated?"
|
|
Header: "Auth"
|
|
Options:
|
|
- "JWT Bearer token (Recommended)" — OAuth2PasswordBearer with JWT decode
|
|
- "API Key header" — X-API-Key header validation
|
|
- "No auth (public)" — Open endpoint, no authentication required
|
|
- "Use existing auth" — Reuse the auth dependency already in the project
|
|
|
|
Question: "Do you need role-based access control?"
|
|
Header: "RBAC"
|
|
Options:
|
|
- "No — any authenticated user" — Single permission level
|
|
- "Yes — role check (admin, user, etc.)" — Require specific roles per endpoint
|
|
- "Yes — ownership check" — Users can only access their own resources
|
|
```
|
|
|
|
### Round 4: Pagination, filtering, caching
|
|
|
|
```
|
|
Question: "What pagination style for list endpoints?"
|
|
Header: "Pagination"
|
|
Options:
|
|
- "Cursor-based (Recommended)" — Best for real-time data, no offset drift
|
|
- "Offset/limit" — Simple, good for admin panels with page numbers
|
|
- "No pagination" — Small datasets, return all results
|
|
|
|
Question: "Do you need response caching?"
|
|
Header: "Caching"
|
|
Options:
|
|
- "No caching" — Fresh data on every request
|
|
- "Cache-Control headers" — Client-side caching via HTTP headers
|
|
- "Redis/in-memory cache" — Server-side caching with TTL
|
|
```
|
|
|
|
## Phase 3: Plan (ExitPlanMode)
|
|
|
|
Write a concrete implementation plan covering:
|
|
|
|
1. **Files to create/modify** — exact paths based on project structure discovered in Phase 1
|
|
2. **Pydantic schemas** — `Create`, `Update`, `Response`, and `List` schemas with field types
|
|
3. **SQLAlchemy model** — table name, columns, relationships, indexes
|
|
4. **CRUD/service layer** — async functions for each operation
|
|
5. **Router** — endpoint signatures, status codes, response models
|
|
6. **Dependencies** — auth, pagination, filtering dependencies
|
|
7. **Tests** — test cases for happy path, validation errors, auth failures, not found
|
|
|
|
Present via ExitPlanMode for user approval.
|
|
|
|
## Phase 4: Execute
|
|
|
|
After approval, implement following this order:
|
|
|
|
### Step 1: Pydantic schemas
|
|
|
|
```python
|
|
from pydantic import BaseModel, ConfigDict
|
|
from datetime import datetime
|
|
from uuid import UUID
|
|
|
|
class ResourceBase(BaseModel):
|
|
"""Shared fields between create and response."""
|
|
name: str
|
|
# ... fields from interview
|
|
|
|
class ResourceCreate(ResourceBase):
|
|
"""Fields required to create the resource."""
|
|
pass
|
|
|
|
class ResourceUpdate(BaseModel):
|
|
"""All fields optional for partial updates."""
|
|
name: str | None = None
|
|
|
|
class ResourceResponse(ResourceBase):
|
|
"""Full resource with DB-generated fields."""
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: UUID
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
class ResourceListResponse(BaseModel):
|
|
"""Paginated list response."""
|
|
data: list[ResourceResponse]
|
|
next_cursor: str | None = None
|
|
has_more: bool
|
|
```
|
|
|
|
### Step 2: SQLAlchemy model
|
|
|
|
```python
|
|
from sqlalchemy import Column, String, DateTime, func
|
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
import uuid
|
|
from app.database import Base
|
|
|
|
class Resource(Base):
|
|
__tablename__ = "resources"
|
|
|
|
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
name = Column(String, nullable=False, index=True)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
```
|
|
|
|
### Step 3: CRUD/service layer
|
|
|
|
```python
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from uuid import UUID
|
|
|
|
async def get_resource(db: AsyncSession, resource_id: UUID) -> Resource | None:
|
|
result = await db.execute(select(Resource).where(Resource.id == resource_id))
|
|
return result.scalar_one_or_none()
|
|
|
|
async def list_resources(
|
|
db: AsyncSession,
|
|
cursor: str | None = None,
|
|
limit: int = 20,
|
|
) -> tuple[list[Resource], str | None]:
|
|
query = select(Resource).order_by(Resource.created_at.desc()).limit(limit + 1)
|
|
if cursor:
|
|
query = query.where(Resource.created_at < decode_cursor(cursor))
|
|
result = await db.execute(query)
|
|
items = list(result.scalars().all())
|
|
next_cursor = encode_cursor(items[-1].created_at) if len(items) > limit else None
|
|
return items[:limit], next_cursor
|
|
|
|
async def create_resource(db: AsyncSession, data: ResourceCreate) -> Resource:
|
|
resource = Resource(**data.model_dump())
|
|
db.add(resource)
|
|
await db.commit()
|
|
await db.refresh(resource)
|
|
return resource
|
|
|
|
async def update_resource(
|
|
db: AsyncSession, resource_id: UUID, data: ResourceUpdate
|
|
) -> Resource | None:
|
|
resource = await get_resource(db, resource_id)
|
|
if not resource:
|
|
return None
|
|
for field, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(resource, field, value)
|
|
await db.commit()
|
|
await db.refresh(resource)
|
|
return resource
|
|
|
|
async def delete_resource(db: AsyncSession, resource_id: UUID) -> bool:
|
|
resource = await get_resource(db, resource_id)
|
|
if not resource:
|
|
return False
|
|
await db.delete(resource)
|
|
await db.commit()
|
|
return True
|
|
```
|
|
|
|
### Step 4: Router with dependencies
|
|
|
|
```python
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from uuid import UUID
|
|
|
|
router = APIRouter(prefix="/resources", tags=["resources"])
|
|
|
|
@router.get("", response_model=ResourceListResponse)
|
|
async def list_resources_endpoint(
|
|
cursor: str | None = Query(None),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user), # if auth required
|
|
):
|
|
items, next_cursor = await list_resources(db, cursor=cursor, limit=limit)
|
|
return ResourceListResponse(
|
|
data=items,
|
|
next_cursor=next_cursor,
|
|
has_more=next_cursor is not None,
|
|
)
|
|
|
|
@router.get("/{resource_id}", response_model=ResourceResponse)
|
|
async def get_resource_endpoint(
|
|
resource_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
resource = await get_resource(db, resource_id)
|
|
if not resource:
|
|
raise HTTPException(status_code=404, detail="Resource not found")
|
|
return resource
|
|
|
|
@router.post("", response_model=ResourceResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_resource_endpoint(
|
|
data: ResourceCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await create_resource(db, data)
|
|
|
|
@router.patch("/{resource_id}", response_model=ResourceResponse)
|
|
async def update_resource_endpoint(
|
|
resource_id: UUID,
|
|
data: ResourceUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
resource = await update_resource(db, resource_id, data)
|
|
if not resource:
|
|
raise HTTPException(status_code=404, detail="Resource not found")
|
|
return resource
|
|
|
|
@router.delete("/{resource_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_resource_endpoint(
|
|
resource_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
deleted = await delete_resource(db, resource_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Resource not found")
|
|
```
|
|
|
|
### Step 5: Tests
|
|
|
|
```python
|
|
import pytest
|
|
from httpx import AsyncClient, ASGITransport
|
|
from app.main import app
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app), base_url="http://test"
|
|
) as ac:
|
|
yield ac
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_resource(client: AsyncClient, auth_headers: dict):
|
|
response = await client.post(
|
|
"/resources",
|
|
json={"name": "Test Resource"},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["name"] == "Test Resource"
|
|
assert "id" in data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_resource_not_found(client: AsyncClient, auth_headers: dict):
|
|
response = await client.get(
|
|
"/resources/00000000-0000-0000-0000-000000000000",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_resources_pagination(client: AsyncClient, auth_headers: dict):
|
|
# Create multiple resources first
|
|
for i in range(5):
|
|
await client.post(
|
|
"/resources",
|
|
json={"name": f"Resource {i}"},
|
|
headers=auth_headers,
|
|
)
|
|
response = await client.get("/resources?limit=2", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["data"]) == 2
|
|
assert data["has_more"] is True
|
|
assert data["next_cursor"] is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_resource_unauthorized(client: AsyncClient):
|
|
response = await client.post("/resources", json={"name": "Test"})
|
|
assert response.status_code in (401, 403)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_resource_partial(client: AsyncClient, auth_headers: dict):
|
|
# Create
|
|
create_resp = await client.post(
|
|
"/resources",
|
|
json={"name": "Original"},
|
|
headers=auth_headers,
|
|
)
|
|
resource_id = create_resp.json()["id"]
|
|
# Partial update
|
|
response = await client.patch(
|
|
f"/resources/{resource_id}",
|
|
json={"name": "Updated"},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["name"] == "Updated"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_resource(client: AsyncClient, auth_headers: dict):
|
|
create_resp = await client.post(
|
|
"/resources",
|
|
json={"name": "To Delete"},
|
|
headers=auth_headers,
|
|
)
|
|
resource_id = create_resp.json()["id"]
|
|
response = await client.delete(
|
|
f"/resources/{resource_id}", headers=auth_headers
|
|
)
|
|
assert response.status_code == 204
|
|
# Verify deleted
|
|
get_resp = await client.get(
|
|
f"/resources/{resource_id}", headers=auth_headers
|
|
)
|
|
assert get_resp.status_code == 404
|
|
```
|
|
|
|
## Key patterns to follow
|
|
|
|
### Dependency injection for auth
|
|
|
|
```python
|
|
from fastapi import Depends, HTTPException, status
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
|
|
|
|
async def get_current_user(
|
|
token: str = Depends(oauth2_scheme),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
payload = decode_jwt(token)
|
|
user = await db.get(User, payload["sub"])
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
return user
|
|
|
|
def require_role(*roles: str):
|
|
"""Factory for role-based access control."""
|
|
async def checker(current_user: User = Depends(get_current_user)):
|
|
if current_user.role not in roles:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
return current_user
|
|
return checker
|
|
```
|
|
|
|
### Cursor-based pagination helper
|
|
|
|
```python
|
|
import base64
|
|
from datetime import datetime
|
|
|
|
def encode_cursor(dt: datetime) -> str:
|
|
return base64.urlsafe_b64encode(dt.isoformat().encode()).decode()
|
|
|
|
def decode_cursor(cursor: str) -> datetime:
|
|
return datetime.fromisoformat(base64.urlsafe_b64decode(cursor).decode())
|
|
```
|
|
|
|
### Error responses
|
|
|
|
Always use FastAPI's `HTTPException` with consistent detail messages. For validation errors, Pydantic v2 handles them automatically via `RequestValidationError` (422).
|
|
|
|
```python
|
|
# 404 — not found
|
|
raise HTTPException(status_code=404, detail="Resource not found")
|
|
|
|
# 409 — conflict (duplicate)
|
|
raise HTTPException(status_code=409, detail="Resource with this name already exists")
|
|
|
|
# 403 — forbidden
|
|
raise HTTPException(status_code=403, detail="Not allowed to modify this resource")
|
|
```
|
|
|
|
## Checklist before finishing
|
|
|
|
- [ ] All endpoints return proper status codes (201 for POST, 204 for DELETE)
|
|
- [ ] Pydantic schemas use `model_config = ConfigDict(from_attributes=True)` for ORM mode
|
|
- [ ] List endpoint has pagination with configurable limit
|
|
- [ ] Auth dependency is applied to all non-public endpoints
|
|
- [ ] Tests cover: happy path, not found, unauthorized, validation errors
|
|
- [ ] Router is registered in the main FastAPI app
|
|
- [ ] Database model has proper indexes on filtered/sorted columns
|