Comprehensive Claude Code guidance system with: - 5 agents: tdd-guardian, code-reviewer, security-scanner, refactor-scan, dependency-audit - 18 skills covering languages (Python, TypeScript, Rust, Go, Java, C#), infrastructure (AWS, Azure, GCP, Terraform, Ansible, Docker/K8s, Database, CI/CD), testing (TDD, UI, Browser), and patterns (Monorepo, API Design, Observability) - 3 hooks: secret detection, auto-formatting, TDD git pre-commit - Strict TDD enforcement with 80%+ coverage requirements - Multi-model strategy: Opus for planning, Sonnet for execution (opusplan) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
463 lines
12 KiB
Markdown
463 lines
12 KiB
Markdown
---
|
|
name: api-design
|
|
description: REST API design patterns with Pydantic/Zod schemas, error handling, and OpenAPI documentation. Use when designing or implementing API endpoints.
|
|
---
|
|
|
|
# API Design Skill
|
|
|
|
## Schema-First Development
|
|
|
|
Always define schemas before implementation. Schemas serve as:
|
|
- Runtime validation
|
|
- Type definitions
|
|
- API documentation
|
|
- Test data factories
|
|
|
|
### Python (Pydantic)
|
|
|
|
```python
|
|
# schemas/user.py
|
|
from datetime import datetime
|
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
|
|
|
|
|
class UserBase(BaseModel):
|
|
"""Shared fields for user schemas."""
|
|
email: EmailStr
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
|
|
|
|
class UserCreate(UserBase):
|
|
"""Request schema for creating a user."""
|
|
password: str = Field(..., min_length=8)
|
|
|
|
@field_validator("password")
|
|
@classmethod
|
|
def password_strength(cls, v: str) -> str:
|
|
if not any(c.isupper() for c in v):
|
|
raise ValueError("Password must contain uppercase")
|
|
if not any(c.isdigit() for c in v):
|
|
raise ValueError("Password must contain digit")
|
|
return v
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
"""Request schema for updating a user (all optional)."""
|
|
email: EmailStr | None = None
|
|
name: str | None = Field(None, min_length=1, max_length=100)
|
|
|
|
|
|
class UserResponse(UserBase):
|
|
"""Response schema (no password)."""
|
|
id: str
|
|
is_active: bool
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class UserListResponse(BaseModel):
|
|
"""Paginated list response."""
|
|
items: list[UserResponse]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
has_more: bool
|
|
```
|
|
|
|
### TypeScript (Zod)
|
|
|
|
```typescript
|
|
// schemas/user.schema.ts
|
|
import { z } from 'zod';
|
|
|
|
export const userBaseSchema = z.object({
|
|
email: z.string().email(),
|
|
name: z.string().min(1).max(100),
|
|
});
|
|
|
|
export const userCreateSchema = userBaseSchema.extend({
|
|
password: z
|
|
.string()
|
|
.min(8)
|
|
.refine((p) => /[A-Z]/.test(p), 'Must contain uppercase')
|
|
.refine((p) => /\d/.test(p), 'Must contain digit'),
|
|
});
|
|
|
|
export const userUpdateSchema = userBaseSchema.partial();
|
|
|
|
export const userResponseSchema = userBaseSchema.extend({
|
|
id: z.string().uuid(),
|
|
isActive: z.boolean(),
|
|
createdAt: z.string().datetime(),
|
|
});
|
|
|
|
export const userListResponseSchema = z.object({
|
|
items: z.array(userResponseSchema),
|
|
total: z.number().int().nonnegative(),
|
|
page: z.number().int().positive(),
|
|
pageSize: z.number().int().positive(),
|
|
hasMore: z.boolean(),
|
|
});
|
|
|
|
// Derived types
|
|
export type UserCreate = z.infer<typeof userCreateSchema>;
|
|
export type UserUpdate = z.infer<typeof userUpdateSchema>;
|
|
export type UserResponse = z.infer<typeof userResponseSchema>;
|
|
export type UserListResponse = z.infer<typeof userListResponseSchema>;
|
|
|
|
// Validation functions for API boundaries
|
|
export const parseUserCreate = (data: unknown) => userCreateSchema.parse(data);
|
|
export const parseUserResponse = (data: unknown) => userResponseSchema.parse(data);
|
|
```
|
|
|
|
## REST Endpoint Patterns
|
|
|
|
### Resource Naming
|
|
```
|
|
GET /users # List users
|
|
POST /users # Create user
|
|
GET /users/{id} # Get single user
|
|
PUT /users/{id} # Full update
|
|
PATCH /users/{id} # Partial update
|
|
DELETE /users/{id} # Delete user
|
|
|
|
# Nested resources
|
|
GET /users/{id}/orders # User's orders
|
|
POST /users/{id}/orders # Create order for user
|
|
|
|
# Actions (when CRUD doesn't fit)
|
|
POST /users/{id}/activate
|
|
POST /orders/{id}/cancel
|
|
```
|
|
|
|
### FastAPI Implementation
|
|
|
|
```python
|
|
# routers/users.py
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.schemas.user import (
|
|
UserCreate,
|
|
UserUpdate,
|
|
UserResponse,
|
|
UserListResponse,
|
|
)
|
|
from app.services.user import UserService
|
|
from app.dependencies import get_db, get_current_user
|
|
|
|
router = APIRouter(prefix="/users", tags=["users"])
|
|
|
|
|
|
@router.get("", response_model=UserListResponse)
|
|
async def list_users(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> UserListResponse:
|
|
"""List users with pagination."""
|
|
service = UserService(db)
|
|
return await service.list_users(page=page, page_size=page_size)
|
|
|
|
|
|
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_user(
|
|
data: UserCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> UserResponse:
|
|
"""Create a new user."""
|
|
service = UserService(db)
|
|
try:
|
|
return await service.create_user(data)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
|
|
|
|
@router.get("/{user_id}", response_model=UserResponse)
|
|
async def get_user(
|
|
user_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> UserResponse:
|
|
"""Get a user by ID."""
|
|
service = UserService(db)
|
|
user = await service.get_user(user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
return user
|
|
|
|
|
|
@router.patch("/{user_id}", response_model=UserResponse)
|
|
async def update_user(
|
|
user_id: str,
|
|
data: UserUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: UserResponse = Depends(get_current_user),
|
|
) -> UserResponse:
|
|
"""Partially update a user."""
|
|
service = UserService(db)
|
|
user = await service.update_user(user_id, data)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
return user
|
|
|
|
|
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_user(
|
|
user_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: UserResponse = Depends(get_current_user),
|
|
) -> None:
|
|
"""Delete a user."""
|
|
service = UserService(db)
|
|
deleted = await service.delete_user(user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Standard Error Response (RFC 7807)
|
|
|
|
```python
|
|
# schemas/error.py
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class ErrorDetail(BaseModel):
|
|
"""Standard error response following RFC 7807."""
|
|
type: str = "about:blank"
|
|
title: str
|
|
status: int
|
|
detail: str
|
|
instance: str | None = None
|
|
|
|
|
|
# Exception handler
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content=ErrorDetail(
|
|
type="validation_error",
|
|
title="Validation Error",
|
|
status=422,
|
|
detail=str(exc.errors()),
|
|
instance=str(request.url),
|
|
).model_dump(),
|
|
)
|
|
```
|
|
|
|
### TypeScript Error Handling
|
|
|
|
```typescript
|
|
// lib/api-client.ts
|
|
import axios, { AxiosError } from 'axios';
|
|
import { z } from 'zod';
|
|
|
|
const errorSchema = z.object({
|
|
type: z.string(),
|
|
title: z.string(),
|
|
status: z.number(),
|
|
detail: z.string(),
|
|
instance: z.string().optional(),
|
|
});
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public title: string,
|
|
public detail: string,
|
|
) {
|
|
super(detail);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: '/api',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
(error: AxiosError) => {
|
|
if (error.response?.data) {
|
|
const parsed = errorSchema.safeParse(error.response.data);
|
|
if (parsed.success) {
|
|
throw new ApiError(
|
|
parsed.data.status,
|
|
parsed.data.title,
|
|
parsed.data.detail,
|
|
);
|
|
}
|
|
}
|
|
throw new ApiError(500, 'Server Error', 'An unexpected error occurred');
|
|
},
|
|
);
|
|
```
|
|
|
|
## Pagination Pattern
|
|
|
|
```python
|
|
# schemas/pagination.py
|
|
from typing import Generic, TypeVar
|
|
from pydantic import BaseModel, Field
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
class PaginatedResponse(BaseModel, Generic[T]):
|
|
"""Generic paginated response."""
|
|
items: list[T]
|
|
total: int
|
|
page: int = Field(ge=1)
|
|
page_size: int = Field(ge=1, le=100)
|
|
|
|
@property
|
|
def has_more(self) -> bool:
|
|
return self.page * self.page_size < self.total
|
|
|
|
@property
|
|
def total_pages(self) -> int:
|
|
return (self.total + self.page_size - 1) // self.page_size
|
|
|
|
|
|
# Usage
|
|
class UserListResponse(PaginatedResponse[UserResponse]):
|
|
pass
|
|
```
|
|
|
|
## Query Parameters
|
|
|
|
```python
|
|
# dependencies/pagination.py
|
|
from fastapi import Query
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class PaginationParams(BaseModel):
|
|
page: int = Query(1, ge=1, description="Page number")
|
|
page_size: int = Query(20, ge=1, le=100, description="Items per page")
|
|
|
|
@property
|
|
def offset(self) -> int:
|
|
return (self.page - 1) * self.page_size
|
|
|
|
|
|
class SortParams(BaseModel):
|
|
sort_by: str = Query("created_at", description="Field to sort by")
|
|
sort_order: str = Query("desc", pattern="^(asc|desc)$")
|
|
|
|
|
|
class FilterParams(BaseModel):
|
|
search: str | None = Query(None, min_length=1, max_length=100)
|
|
status: str | None = Query(None, pattern="^(active|inactive|pending)$")
|
|
created_after: datetime | None = Query(None)
|
|
created_before: datetime | None = Query(None)
|
|
```
|
|
|
|
## OpenAPI Documentation
|
|
|
|
```python
|
|
# main.py
|
|
from fastapi import FastAPI
|
|
from fastapi.openapi.utils import get_openapi
|
|
|
|
app = FastAPI(
|
|
title="My API",
|
|
description="API for managing resources",
|
|
version="1.0.0",
|
|
docs_url="/docs",
|
|
redoc_url="/redoc",
|
|
)
|
|
|
|
def custom_openapi():
|
|
if app.openapi_schema:
|
|
return app.openapi_schema
|
|
|
|
openapi_schema = get_openapi(
|
|
title=app.title,
|
|
version=app.version,
|
|
description=app.description,
|
|
routes=app.routes,
|
|
)
|
|
|
|
# Add security scheme
|
|
openapi_schema["components"]["securitySchemes"] = {
|
|
"bearerAuth": {
|
|
"type": "http",
|
|
"scheme": "bearer",
|
|
"bearerFormat": "JWT",
|
|
}
|
|
}
|
|
|
|
app.openapi_schema = openapi_schema
|
|
return app.openapi_schema
|
|
|
|
app.openapi = custom_openapi
|
|
```
|
|
|
|
## HTTP Status Codes
|
|
|
|
| Code | Meaning | When to Use |
|
|
|------|---------|-------------|
|
|
| 200 | OK | Successful GET, PUT, PATCH |
|
|
| 201 | Created | Successful POST creating resource |
|
|
| 204 | No Content | Successful DELETE |
|
|
| 400 | Bad Request | Invalid request body/params |
|
|
| 401 | Unauthorized | Missing/invalid authentication |
|
|
| 403 | Forbidden | Authenticated but not authorized |
|
|
| 404 | Not Found | Resource doesn't exist |
|
|
| 409 | Conflict | Duplicate resource (e.g., email exists) |
|
|
| 422 | Unprocessable | Validation error |
|
|
| 500 | Server Error | Unexpected server error |
|
|
|
|
## Anti-Patterns
|
|
|
|
```python
|
|
# BAD: Returning different shapes
|
|
@router.get("/users/{id}")
|
|
async def get_user(id: str):
|
|
user = await get_user(id)
|
|
if user:
|
|
return user # UserResponse
|
|
return {"error": "not found"} # Different shape!
|
|
|
|
# GOOD: Consistent response or exception
|
|
@router.get("/users/{id}", response_model=UserResponse)
|
|
async def get_user(id: str):
|
|
user = await get_user(id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
return user
|
|
|
|
|
|
# BAD: Exposing internal details
|
|
class UserResponse(BaseModel):
|
|
id: str
|
|
email: str
|
|
hashed_password: str # NEVER expose!
|
|
internal_notes: str # Internal only!
|
|
|
|
# GOOD: Explicit public fields
|
|
class UserResponse(BaseModel):
|
|
id: str
|
|
email: str
|
|
name: str
|
|
# Only fields clients need
|
|
|
|
|
|
# BAD: No validation at boundary
|
|
@router.post("/users")
|
|
async def create_user(data: dict): # Unvalidated!
|
|
return await service.create(data)
|
|
|
|
# GOOD: Schema validation
|
|
@router.post("/users", response_model=UserResponse)
|
|
async def create_user(data: UserCreate): # Validated!
|
|
return await service.create(data)
|
|
```
|