Skill: wpank/api-design-principles (version 1.0.0)

--- name: api-design-principles model: reasoning --- # API Design Principles ## WHAT Design intuitive, scalable REST and GraphQL APIs that developers love. Covers resource modeling, HTTP semantics, pagination, error handling, versioning, and GraphQL schema patterns. ## WHEN - Designing new REST or GraphQL APIs - Reviewing API specifications before implementation - Establishing API design standards for teams - Refactoring APIs for better usability - Migrating between API paradigms ## KEYWORDS REST, GraphQL, API design, HTTP methods, pagination, error handling, versioning, OpenAPI, HATEOAS, schema design --- ## Decision Framework: REST vs GraphQL | Choose REST when... | Choose GraphQL when... | |---------------------|------------------------| | Simple CRUD operations | Complex nested data requirements | | Public APIs with broad audience | Mobile apps needing bandwidth optimization | | Heavy caching requirements | Clients need to specify exact data shape | | Team is unfamiliar with GraphQL | Aggregating multiple data sources | | Simple response structures | Rapidly evolving frontend requirements | --- ## REST API Design ### Resource Naming Rules ``` ✓ Plural nouns for collections GET /api/users GET /api/orders GET /api/products ✗ Avoid verbs (let HTTP methods be the verb) POST /api/createUser ← Wrong POST /api/users ← Correct ✓ Nested resources (max 2 levels) GET /api/users/{id}/orders ✗ Avoid deep nesting GET /api/users/{id}/orders/{orderId}/items/{itemId}/reviews ← Too deep GET /api/order-items/{id}/reviews ← Better ``` ### HTTP Methods and Status Codes | Method | Purpose | Success | Common Errors | |--------|---------|---------|---------------| | GET | Retrieve | 200 OK | 404 Not Found | | POST | Create | 201 Created | 400/422 Validation | | PUT | Replace | 200 OK | 404 Not Found | | PATCH | Partial update | 200 OK | 404 Not Found | | DELETE | Remove | 204 No Content | 404/409 Conflict | ### Complete Status Code Reference ```python SUCCESS = { 200: "OK", # GET, PUT, PATCH success 201: "Created", # POST success 204: "No Content", # DELETE success } CLIENT_ERROR = { 400: "Bad Request", # Malformed syntax 401: "Unauthorized", # Missing/invalid auth 403: "Forbidden", # Valid auth, no permission 404: "Not Found", # Resource doesn't exist 409: "Conflict", # State conflict (duplicate email) 422: "Unprocessable Entity", # Validation errors 429: "Too Many Requests", # Rate limited } SERVER_ERROR = { 500: "Internal Server Error", 503: "Service Unavailable", # Temporary downtime } ``` ### Pagination #### Offset-Based (Simple) ```python GET /api/users?page=2&page_size=20 { "items": [...], "page": 2, "page_size": 20, "total": 150, "pages": 8 } ``` #### Cursor-Based (For Large Datasets) ```python GET /api/users?limit=20&cursor=eyJpZCI6MTIzfQ { "items": [...], "next_cursor": "eyJpZCI6MTQzfQ", "has_more": true } ``` ### Filtering and Sorting ``` # Filtering GET /api/users?status=active&role=admin # Sorting (- prefix for descending) GET /api/users?sort=-created_at,name # Search GET /api/users?search=john # Field selection GET /api/users?fields=id,name,email ``` ### Error Response Format Always use consistent structure: ```json { "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ {"field": "email", "message": "Invalid email format"} ], "timestamp": "2025-10-16T12:00:00Z" } } ``` ### FastAPI Implementation ```python from fastapi import FastAPI, Query, Path, HTTPException, status from pydantic import BaseModel, Field, EmailStr from typing import Optional, List from datetime import datetime app = FastAPI(title="API", version="1.0.0") # Models class UserCreate(BaseModel): email: EmailStr name: str = Field(..., min_length=1, max_length=100) class User(BaseModel): id: str email: str name: str created_at: datetime class PaginatedResponse(BaseModel): items: List[User] total: int page: int page_size: int pages: int # Endpoints @app.get("/api/users", response_model=PaginatedResponse) async def list_users( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: Optional[str] = Query(None), search: Optional[str] = Query(None) ): """List users with pagination and filtering.""" total = await count_users(status=status, search=search) offset = (page - 1) * page_size users = await fetch_users(limit=page_size, offset=offset, status=status, search=search) return PaginatedResponse( items=users, total=total, page=page, page_size=page_size, pages=(total + page_size - 1) // page_size ) @app.post("/api/users", response_model=User, status_code=status.HTTP_201_CREATED) async def create_user(user: UserCreate): """Create new user.""" if await user_exists(user.email): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"code": "EMAIL_EXISTS", "message": "Email already registered"} ) return await save_user(user) @app.get("/api/users/{user_id}", response_model=User) async def get_user(user_id: str = Path(...)): """Get user by ID.""" user = await fetch_user(user_id) if not user: raise HTTPException(status_code=404, detail="User not found") return user @app.delete("/api/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user(user_id: str): """Delete user.""" if not await fetch_user(user_id): raise HTTPException(status_code=404, detail="User not found") await remove_user(user_id) ``` --- ## GraphQL API Design ### Schema Structure ```graphql # Types type User { id: ID! email: String! name: String! createdAt: DateTime! orders(first: Int = 20, after: String): OrderConnection! } # Pagination (Relay-style) type OrderConnection { edges: [OrderEdge!]! pageInfo: PageInfo! totalCount: Int! } type OrderEdge { node: Order! cursor: String! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String } # Queries type Query { user(id: ID!): User users(first: Int = 20, after: String, search: String): UserConnection! } # Mutations with Input/Payload pattern input CreateUserInput { email: String! name: String! [REDACTED_SECRET] } type CreateUserPayload { user: User errors: [Error!] } type Error { field: String message: String! code: String! } type Mutation { createUser(input: CreateUserInput!): CreateUserPayload! } ``` ### DataLoader (Prevent N+1) ```python from aiodataloader import DataLoader class UserLoader(DataLoader): async def batch_load_fn(self, user_ids: List[str]) -> List[Optional[dict]]: """Load multiple users in single query.""" users = await fetch_users_by_ids(user_ids) user_map = {user["id"]: user for user in users} return [user_map.get(uid) for uid in user_ids] # In resolver @user_type.field("orders") async def resolve_orders(user: dict, info): loader = info.context["loaders"]["orders_by_user"] return await loader.load(user["id"]) ``` ### Query Protection ```python # Depth limiting MAX_QUERY_DEPTH = 5 # Complexity limiting MAX_QUERY_COMPLEXITY = 100 # Timeout QUERY_TIMEOUT_SECONDS = 10 ``` --- ## Versioning Strategies ### URL Versioning (Recommended) ``` /api/v1/users /api/v2/users ``` **Pros**: Clear, easy to route, cacheable **Cons**: Multiple URLs for same resource ### Header Versioning ``` GET /api/users Accept: application/vnd.api+json; version=2 ``` **Pros**: Clean URLs **Cons**: Less visible, harder to test ### Deprecation Strategy 1. Add deprecation headers: `Deprecation: true` 2. Document migration path 3. Give 6-12 months notice 4. Monitor usage before removal --- ## Rate Limiting ### Headers ``` X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 742 X-RateLimit-Reset: 1640000000 # When limited: 429 Too Many Requests Retry-After: 3600 ``` ### Implementation ```python from datetime import datetime, timedelta class RateLimiter: def __init__(self, calls: int, period: int): self.calls = calls self.period = period self.cache = {} def check(self, key: str) -> tuple[bool, dict]: now = datetime.now() if key not in self.cache: self.cache[key] = [] # Remove old requests cutoff = now - timedelta(seconds=self.period) self.cache[key] = [ts for ts in self.cache[key] if ts > cutoff] remaining = self.calls - len(self.cache[key]) if remaining <= 0: return False, {"limit": self.calls, "remaining": 0} self.cache[key].append(now) return True, {"limit": self.calls, "remaining": remaining - 1} ``` --- ## Pre-Implementation Checklist ### Resources - [ ] Nouns, not verbs - [ ] Plural for collections - [ ] Max 2 levels nesting ### HTTP - [ ] Correct method for each action - [ ] Correct status codes - [ ] Idempotent operations are idempotent ### Data - [ ] All collections paginated - [ ] Filtering/sorting supported - [ ] Error format consistent ### Security - [ ] Authentication defined - [ ] Rate limiting configured - [ ] Input validation on all fields - [ ] HTTPS enforced ### Documentation - [ ] OpenAPI spec generated - [ ] All endpoints documented - [ ] Examples provided --- ## NEVER - **Verbs in URLs**: `/api/getUser` → use `/api/users/{id}` with GET - **POST for Retrieval**: Use GET for safe, idempotent reads - **Inconsistent Errors**: Always same error format - **Unbounded Lists**: Always paginate collections - **Secrets in URLs**: Query params are logged - **Breaking Changes Without Versioning**: Plan for evolution from day 1 - **Database Schema as API**: API should be stable even when schema changes - **Ignoring HTTP Semantics**: Status codes and methods have meaning

path: README.md
content: # API Design Principles

Design intuitive, scalable REST and GraphQL APIs that developers love. Covers resource modeling, HTTP semantics, pagination, error handling, versioning, and GraphQL schema patterns.

## What's Inside

- Decision Framework — REST vs GraphQL comparison
- REST API Design — resource naming rules, HTTP methods and status codes, complete status code reference
- Pagination — offset-based and cursor-based strategies
- Filtering and Sorting patterns
- Error Response Format with consistent structure
- FastAPI Implementation example with CRUD, pagination, and filtering
- GraphQL API Design — schema structure, Relay-style pagination, input/payload mutation pattern
- DataLoader for N+1 prevention
- Query Protection — depth limiting, complexity limiting, timeout
- Versioning Strategies — URL versioning, header versioning, deprecation strategy
- Rate Limiting headers and implementation
- Pre-Implementation Checklist — resources, HTTP, data, security, documentation

## When to Use

- Designing new REST or GraphQL APIs
- Reviewing API specifications before implementation
- Establishing API design standards for teams
- Refactoring APIs for better usability
- Migrating between API paradigms

## Installation

```bash
npx add https://github.com/wpank/ai/tree/main/skills/backend/api-design-principles
```

### Manual Installation

#### Cursor (per-project)

From your project root:

```bash
mkdir -p .cursor/skills
cp -r ~/.ai-skills/skills/backend/api-design-principles .cursor/skills/api-design-principles
```

#### Cursor (global)

```bash
mkdir -p ~/.cursor/skills
cp -r ~/.ai-skills/skills/backend/api-design-principles ~/.cursor/skills/api-design-principles
```

#### Claude Code (per-project)

From your project root:

```bash
mkdir -p .claude/skills
cp -r ~/.ai-skills/skills/backend/api-design-principles .claude/skills/api-design-principles
```

#### Claude Code (global)

```bash
mkdir -p ~/.claude/skills
cp -r ~/.ai-skills/skills/backend/api-design-principles ~/.claude/skills/api-design-principles
```

---

Part of the [Backend](..) skill category.


path: templates/fastapi-template.py
content: """
Production-ready REST API template using FastAPI.
Includes pagination, filtering, error handling, and best practices.
"""

from fastapi import FastAPI, HTTPException, Query, Path, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, EmailStr, ConfigDict
from typing import Optional, List, Any, Generic, TypeVar
from datetime import datetime
from enum import Enum

# App setup
app = FastAPI(
    title="API Template",
    version="1.0.0",
    docs_url="/api/docs",
    redoc_url="/api/redoc"
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # Configure for production
    allow_methods=["*"],
    allow_headers=["*"],
)


# ============================================================================
# Models
# ============================================================================

class UserStatus(str, Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"
    SUSPENDED = "suspended"


class UserCreate(BaseModel):
    """Request body for creating a user."""
    email: EmailStr
    name: str = Field(..., min_length=1, max_length=100)


class UserUpdate(BaseModel):
    """Request body for updating a user (partial)."""
    email: Optional[EmailStr] = None
    name: Optional[str] = Field(None, min_length=1, max_length=100)
    status: Optional[UserStatus] = None


class User(BaseModel):
    """User response model."""
    id: str
    email: str
    name: str
    status: UserStatus
    created_at: datetime
    updated_at: datetime

    model_config = ConfigDict(from_attributes=True)


# ============================================================================
# Pagination
# ============================================================================

T = TypeVar('T')


class PaginatedResponse(BaseModel, Generic[T]):
    """Generic paginated response."""
    items: List[T]
    total: int
    page: int
    page_size: int
    pages: int

    @property
    def has_next(self) -> bool:
        return self.page < self.pages

    @property
    def has_prev(self) -> bool:
        return self.page > 1


# ============================================================================
# Error Handling
# ============================================================================

class ErrorDetail(BaseModel):
    field: Optional[str] = None
    message: str
    code: str


class ErrorResponse(BaseModel):
    error: str
    message: str
    details: Optional[List[ErrorDetail]] = None
    timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())


@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
    content = {
        "error": exc.__class__.__name__,
        "message": exc.detail if isinstance(exc.detail, str) else exc.detail.get("message", "Error"),
        "timestamp": datetime.now().isoformat()
    }
    if isinstance(exc.detail, dict) and "details" in exc.detail:
        content["details"] = exc.detail["details"]
    
    return JSONResponse(status_code=exc.status_code, content=content)


def raise_not_found(resource: str, resource_id: str):
    """Raise 404 with consistent format."""
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail={"message": f"{resource} not found", "resource_id": resource_id}
    )


def raise_conflict(message: str, field: str = None):
    """Raise 409 conflict."""
    raise HTTPException(
        status_code=status.HTTP_409_CONFLICT,
        detail={"message": message, "field": field}
    )


# ============================================================================
# Endpoints
# ============================================================================

@app.get("/api/users", response_model=PaginatedResponse[User], tags=["Users"])
async def list_users(
    page: int = Query(1, ge=1, description="Page number"),
    page_size: int = Query(20, ge=1, le=100, description="Items per page"),
    status: Optional[UserStatus] = Query(None, description="Filter by status"),
    search: Optional[str] = Query(None, description="Search by name/email"),
    sort: Optional[str] = Query("-created_at", description="Sort field (prefix - for desc)")
):
    """
    List users with pagination, filtering, and sorting.
    
    - **page**: Page number (1-indexed)
    - **page_size**: Number of items per page (max 100)
    - **status**: Filter by user status
    - **search**: Search in name and email
    - **sort**: Sort field, prefix with - for descending
    """
    # Implementation: Replace with actual database query
    total = 100
    items = []  # fetch_users(...)
    
    return PaginatedResponse(
        items=items,
        total=total,
        page=page,
        page_size=page_size,
        pages=(total + page_size - 1) // page_size
    )


@app.post(
    "/api/users",
    response_model=User,
    status_code=status.HTTP_201_CREATED,
    tags=["Users"]
)
async def create_user(user: UserCreate):
    """
    Create a new user.
    
    Returns 201 Created with the new user.
    Returns 409 Conflict if email already exists.
    """
    # Check for existing user
    # if await user_exists(user.email):
    #     raise_conflict("Email already registered", "email")
    
    # Create and return user
    return User(
        id="new-id",
        email=user.email,
        name=user.name,
        status=UserStatus.ACTIVE,
        created_at=datetime.now(),
        updated_at=datetime.now()
    )


@app.get("/api/users/{user_id}", response_model=User, tags=["Users"])
async def get_user(user_id: str = Path(..., description="User ID")):
    """
    Get user by ID.
    
    Returns 404 if user not found.
    """
    # user = await fetch_user(user_id)
    # if not user:
    #     raise_not_found("User", user_id)
    # return user
    raise_not_found("User", user_id)


@app.patch("/api/users/{user_id}", response_model=User, tags=["Users"])
async def update_user(
    user_id: str = Path(..., description="User ID"),
    update: UserUpdate = ...
):
    """
    Partially update user.
    
    Only provided fields are updated.
    """
    # existing = await fetch_user(user_id)
    # if not existing:
    #     raise_not_found("User", user_id)
    # 
    # update_data = update.model_dump(exclude_unset=True)
    # updated = await update_user_in_db(user_id, update_data)
    # return updated
    raise_not_found("User", user_id)


@app.delete(
    "/api/users/{user_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    tags=["Users"]
)
async def delete_user(user_id: str = Path(..., description="User ID")):
    """
    Delete user.
    
    Returns 204 No Content on success.
    Returns 404 if user not found.
    """
    # if not await fetch_user(user_id):
    #     raise_not_found("User", user_id)
    # await delete_user_from_db(user_id)
    pass


# ============================================================================
# Health Checks
# ============================================================================

@app.get("/health", tags=["Health"])
async def health_check():
    """Basic health check."""
    return {"status": "healthy", "timestamp": datetime.now().isoformat()}


@app.get("/health/ready", tags=["Health"])
async def readiness_check():
    """Detailed readiness check with dependency status."""
    checks = {
        "database": True,  # await check_database()
        "cache": True,     # await check_redis()
    }
    all_healthy = all(checks.values())
    
    return {
        "status": "ready" if all_healthy else "not_ready",
        "checks": checks,
        "timestamp": datetime.now().isoformat()
    }


# ============================================================================
# Run
# ============================================================================

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)


path: references/quick-reference.md
content: # API Design Quick Reference

## HTTP Methods at a Glance

| Method | Safe | Idempotent | Request Body | Response Body |
|--------|------|------------|--------------|---------------|
| GET | Yes | Yes | No | Yes |
| POST | No | No | Yes | Yes |
| PUT | No | Yes | Yes | Yes |
| PATCH | No | No | Yes | Yes |
| DELETE | No | Yes | No | No |

## Status Codes Cheat Sheet

### 2xx Success
- **200 OK** - GET, PUT, PATCH success
- **201 Created** - POST success (include Location header)
- **204 No Content** - DELETE success

### 4xx Client Error
- **400 Bad Request** - Malformed request syntax
- **401 Unauthorized** - Missing or invalid authentication
- **403 Forbidden** - Authenticated but not authorized
- **404 Not Found** - Resource doesn't exist
- **409 Conflict** - State conflict (duplicate, version mismatch)
- **422 Unprocessable Entity** - Validation failed
- **429 Too Many Requests** - Rate limited

### 5xx Server Error
- **500 Internal Server Error** - Unexpected error
- **503 Service Unavailable** - Temporarily down

## URL Patterns

```
# Collection
GET    /api/resources          → List all
POST   /api/resources          → Create new

# Item
GET    /api/resources/{id}     → Get one
PUT    /api/resources/{id}     → Replace
PATCH  /api/resources/{id}     → Partial update
DELETE /api/resources/{id}     → Remove

# Nested (max 1 level)
GET    /api/users/{id}/orders  → User's orders

# Actions (when REST doesn't fit)
POST   /api/orders/{id}/cancel → Action on resource
```

## Query Parameters

```
# Pagination
?page=2&page_size=20
?limit=20&cursor=abc123

# Filtering
?status=active
?status=active,pending

# Sorting
?sort=created_at
?sort=-created_at        (descending)
?sort=status,-created_at (multiple)

# Search
?search=keyword
?q=keyword

# Field selection
?fields=id,name,email
```

## Standard Headers

### Request
```
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/json
Idempotency-Key: <unique-id>
```

### Response
```
Content-Type: application/json
Location: /api/resources/123       (after 201)
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 742
X-RateLimit-Reset: 1640000000
Cache-Control: public, max-age=3600
ETag: "33a64df551"
```

## Error Response Template

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Human readable message",
    "details": [
      {
        "field": "email",
        "message": "Invalid email format",
        "value": "not-an-email"
      }
    ],
    "timestamp": "2025-01-15T10:30:00Z",
    "request_id": "req_abc123"
  }
}
```

## Pagination Response Template

```json
{
  "items": [...],
  "pagination": {
    "page": 2,
    "page_size": 20,
    "total": 150,
    "pages": 8,
    "has_next": true,
    "has_prev": true
  }
}
```

## GraphQL Quick Reference

### Query
```graphql
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    orders(first: 10) {
      edges {
        node {
          id
          total
        }
      }
      pageInfo {
        hasNextPage
      }
    }
  }
}
```

### Mutation
```graphql
mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    user {
      id
      name
    }
    errors {
      field
      message
    }
  }
}
```

## Common Mistakes to Avoid

| Wrong | Right |
|-------|-------|
| `POST /getUsers` | `GET /users` |
| `GET /createUser` | `POST /users` |
| `POST /users/delete/123` | `DELETE /users/123` |
| `/user/123` | `/users/123` (plural) |
| `/api/users/123/orders/456/items` | Max 2 levels |
| 200 for everything | Correct status codes |
| Different error formats | Consistent structure |


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)