Claude-code-templates fastapi-endpoint
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.
install
source · Clone the upstream repo
git clone https://github.com/davila7/claude-code-templates
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/davila7/claude-code-templates "$T" && mkdir -p ~/.claude/skills && cp -r "$T/cli-tool/components/skills/web-development/fastapi-endpoint" ~/.claude/skills/davila7-claude-code-templates-fastapi-endpoint && rm -rf "$T"
manifest:
cli-tool/components/skills/web-development/fastapi-endpoint/SKILL.mdsource content
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
, orapp.py
)app/__init__.py - Identify the router organization pattern (single file vs
directory)routers/ - Check for existing
,models/
,schemas/
, orcrud/
directoriesservices/ - Look at
orpyproject.toml
for installed dependenciesrequirements.txt
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? (
, middleware, other)Depends(get_db) - 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:
- Files to create/modify — exact paths based on project structure discovered in Phase 1
- Pydantic schemas —
,Create
,Update
, andResponse
schemas with field typesList - SQLAlchemy model — table name, columns, relationships, indexes
- CRUD/service layer — async functions for each operation
- Router — endpoint signatures, status codes, response models
- Dependencies — auth, pagination, filtering dependencies
- 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
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
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
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
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
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
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
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).
# 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
for ORM modemodel_config = ConfigDict(from_attributes=True) - 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