Files
ai-development-scaffold/.claude/skills/patterns/api-design/SKILL.md
James Bland befb8fbaeb feat: initial Claude Code configuration scaffold
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>
2026-01-20 15:47:34 -05:00

12 KiB

name, description
name description
api-design 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)

# 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)

// 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

# 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)

# 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

// 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

# 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

# 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

# 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

# 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)