chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user