api-design
API Design Skill
REST API design best practices, HTTP conventions, versioning, error handling, and documentation standards.
When This Skill Activates
- Designing REST APIs
- Creating HTTP endpoints
- Writing API documentation
- Handling API errors
- Implementing pagination
- API versioning strategies
- Keywords: "api", "rest", "endpoint", "http", "json", "openapi"
REST Principles
RESTful Resource Design
Resources are nouns, not verbs:
# ✅ GOOD: Resource-based
GET /users # List users
GET /users/123 # Get user 123
POST /users # Create user
PUT /users/123 # Update user 123
DELETE /users/123 # Delete user 123
# ❌ BAD: Action-based
GET /getUsers
POST /createUser
POST /updateUser
POST /deleteUser
HTTP Methods (Verbs)
| Method | Purpose | Idempotent? | Safe? |
|---|---|---|---|
| GET | Read resource | ✅ Yes | ✅ Yes |
| POST | Create resource | ❌ No | ❌ No |
| PUT | Replace resource | ✅ Yes | ❌ No |
| PATCH | Update partial resource | ❌ No | ❌ No |
| DELETE | Delete resource | ✅ Yes | ❌ No |
Idempotent: Same request → same result (can retry safely) Safe: No side effects (doesn't modify data)
URL Structure
Resource Naming
Use plural nouns:
# ✅ GOOD: Plural
/users
/posts
/comments
# ❌ BAD: Singular
/user
/post
/comment
Use hierarchical structure for relationships:
# ✅ GOOD: Nested resources
GET /users/123/posts # Posts by user 123
GET /posts/456/comments # Comments on post 456
POST /users/123/posts # Create post for user 123
# ❌ BAD: Flat structure
GET /posts?user_id=123 # Less clear
Keep URLs shallow (max 3 levels):
# ✅ GOOD: 2-3 levels
/users/123/posts
/posts/456/comments
# ❌ BAD: Too deep
/users/123/posts/456/comments/789/replies
# Use: /comments/789/replies instead
Query Parameters
Use for filtering, sorting, pagination:
# Filtering
GET /users?role=admin
GET /users?created_after=2024-01-01
# Sorting
GET /posts?sort=created_at&order=desc
GET /posts?sort=-created_at # - prefix for descending
# Pagination
GET /users?page=2&limit=20
GET /users?offset=40&limit=20
# Search
GET /users?q=john
GET /posts?search=python
HTTP Status Codes
Success Codes (2xx)
200 OK - Request succeeded (GET, PUT, PATCH)
201 Created - Resource created (POST)
204 No Content - Success, no response body (DELETE)
Examples:
# 200 OK - Return resource
@app.get("/users/{user_id}")
def get_user(user_id: int):
user = db.get_user(user_id)
return JSONResponse(content=user, status_code=200)
# 201 Created - Return created resource + Location header
@app.post("/users")
def create_user(user: User):
created = db.create_user(user)
return JSONResponse(
content=created,
status_code=201,
headers={"Location": f"/users/{created['id']}"}
)
# 204 No Content - No body needed
@app.delete("/users/{user_id}")
def delete_user(user_id: int):
db.delete_user(user_id)
return Response(status_code=204)
Client Error Codes (4xx)
400 Bad Request - Invalid request body/parameters
401 Unauthorized - Authentication required
403 Forbidden - Authenticated but not allowed
404 Not Found - Resource doesn't exist
409 Conflict - Conflict (e.g., duplicate email)
422 Unprocessable - Validation error
429 Too Many Requests - Rate limit exceeded
Server Error Codes (5xx)
500 Internal Server Error - Unexpected server error
503 Service Unavailable - Server temporarily down
Error Response Format
RFC 7807 (Problem Details)
Standard error format:
{
"type": "https://example.com/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "Email address is invalid",
"instance": "/users",
"errors": {
"email": ["Must be a valid email address"],
"password": ["Must be at least 8 characters"]
}
}
Implementation:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
class ErrorResponse(BaseModel):
type: str
title: str
status: int
detail: str
instance: str
errors: dict = {}
@app.post("/users")
def create_user(user: User):
if not validate_email(user.email):
raise HTTPException(
status_code=422,
detail={
"type": "https://example.com/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "Invalid email address",
"instance": "/users",
"errors": {
"email": ["Must be a valid email address"]
}
}
)
Consistent Error Structure
Minimal error (for simple cases):
{
"error": "Invalid email address",
"code": "VALIDATION_ERROR"
}
Detailed error (for complex cases):
{
"error": "Validation failed",
"code": "VALIDATION_ERROR",
"message": "One or more fields failed validation",
"fields": {
"email": "Must be a valid email address",
"password": "Must be at least 8 characters"
},
"timestamp": "2025-10-24T12:00:00Z",
"path": "/users"
}
Request/Response Format
Request Body (POST/PUT/PATCH)
JSON format:
POST /users
Content-Type: application/json
{
"email": "user@example.com",
"name": "John Doe",
"role": "admin"
}
Python (FastAPI):
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
name: str
role: str
@app.post("/users")
def create_user(user: UserCreate):
# user.email, user.name, user.role automatically validated
return db.create_user(user.dict())
Response Body
Single resource:
GET /users/123
{
"id": 123,
"email": "user@example.com",
"name": "John Doe",
"created_at": "2025-10-24T12:00:00Z"
}
Collection:
GET /users
{
"data": [
{"id": 1, "email": "user1@example.com"},
{"id": 2, "email": "user2@example.com"}
],
"meta": {
"total": 100,
"page": 1,
"limit": 20,
"pages": 5
}
}
Pagination
Offset-Based Pagination
Query parameters:
GET /users?page=2&limit=20
GET /users?offset=40&limit=20
Response:
{
"data": [...],
"meta": {
"total": 100,
"offset": 40,
"limit": 20,
"next": "/users?offset=60&limit=20",
"prev": "/users?offset=20&limit=20"
}
}
Implementation:
@app.get("/users")
def list_users(page: int = 1, limit: int = 20):
offset = (page - 1) * limit
users = db.get_users(offset=offset, limit=limit)
total = db.count_users()
return {
"data": users,
"meta": {
"total": total,
"page": page,
"limit": limit,
"pages": (total + limit - 1) // limit
}
}
Pros: Simple, can jump to any page Cons: Inconsistent if data changes between requests
Cursor-Based Pagination
Better for real-time data:
GET /users?cursor=abc123&limit=20
Response:
{
"data": [...],
"meta": {
"next_cursor": "def456",
"prev_cursor": "xyz789",
"has_more": true
}
}
Implementation:
@app.get("/users")
def list_users(cursor: str = None, limit: int = 20):
users = db.get_users_after_cursor(cursor, limit)
next_cursor = users[-1].id if users else None
return {
"data": users,
"meta": {
"next_cursor": next_cursor,
"has_more": len(users) == limit
}
}
Pros: Consistent results, works with real-time data Cons: Can't jump to arbitrary page
API Versioning
URL Path Versioning (Recommended)
# ✅ GOOD: Version in URL
GET /v1/users
GET /v2/users
Pros:
- Simple, clear
- Easy to route
- Cached separately
Cons:
- URL changes
Implementation:
# FastAPI
app = FastAPI()
v1_router = APIRouter(prefix="/v1")
v2_router = APIRouter(prefix="/v2")
@v1_router.get("/users")
def list_users_v1():
return {"version": 1, "users": [...]}
@v2_router.get("/users")
def list_users_v2():
return {"version": 2, "users": [...]}
app.include_router(v1_router)
app.include_router(v2_router)
Header Versioning
GET /users
Accept: application/vnd.myapi.v1+json
Pros:
- Same URL
- Semantic
Cons:
- Harder to test (need headers)
- Not cached separately
Breaking Changes
What requires a new version:
- ❌ Remove field
- ❌ Rename field
- ❌ Change field type
- ❌ Add required field
- ✅ Add optional field (backward compatible)
Example:
// v1
{"id": 1, "name": "John"}
// v2 - Breaking change (renamed field)
{"id": 1, "full_name": "John"} // Need /v2/users
// v2 - Non-breaking (added optional field)
{"id": 1, "name": "John", "email": "john@example.com"} // Can keep /v1/users
Authentication & Authorization
API Key (Simple)
GET /users
Authorization: Bearer sk-abc123...
Implementation:
from fastapi import Security, HTTPException
from fastapi.security import HTTPBearer
security = HTTPBearer()
@app.get("/users")
def list_users(credentials: HTTPAuthorizationCredentials = Security(security)):
api_key = credentials.credentials
if not validate_api_key(api_key):
raise HTTPException(status_code=401, detail="Invalid API key")
return get_users()
JWT (Stateless)
GET /users
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Implementation:
import jwt
from datetime import datetime, timedelta
SECRET = "your-secret-key"
def create_token(user_id: int) -> str:
payload = {
"user_id": user_id,
"exp": datetime.utcnow() + timedelta(hours=1)
}
return jwt.encode(payload, SECRET, algorithm="HS256")
def verify_token(token: str) -> dict:
try:
return jwt.decode(token, SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@app.get("/users")
def list_users(token: str = Security(security)):
payload = verify_token(token)
user_id = payload["user_id"]
return get_users_for(user_id)
Rate Limiting
Headers
X-RateLimit-Limit: 1000 # Max requests per hour
X-RateLimit-Remaining: 999 # Requests remaining
X-RateLimit-Reset: 1698768000 # Unix timestamp when limit resets
Implementation:
from fastapi import Request, HTTPException
from datetime import datetime, timedelta
import redis
redis_client = redis.Redis()
RATE_LIMIT = 1000 # per hour
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_ip = request.client.host
key = f"rate_limit:{client_ip}"
# Increment counter
current = redis_client.incr(key)
# Set expiration on first request
if current == 1:
redis_client.expire(key, 3600) # 1 hour
# Get TTL
ttl = redis_client.ttl(key)
reset_time = datetime.now() + timedelta(seconds=ttl)
# Check limit
if current > RATE_LIMIT:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded",
headers={
"X-RateLimit-Limit": str(RATE_LIMIT),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(int(reset_time.timestamp()))
}
)
# Add headers
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
response.headers["X-RateLimit-Remaining"] = str(RATE_LIMIT - current)
response.headers["X-RateLimit-Reset"] = str(int(reset_time.timestamp()))
return response
CORS (Cross-Origin Resource Sharing)
Allow browser requests from different domains:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://example.com"], # Specific origins
# allow_origins=["*"], # All origins (development only!)
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
Filtering & Sorting
Filtering
# Single filter
GET /users?role=admin
# Multiple filters
GET /users?role=admin&status=active
# Range filters
GET /posts?created_after=2024-01-01&created_before=2024-12-31
# Search
GET /users?q=john
Implementation:
@app.get("/users")
def list_users(
role: str = None,
status: str = None,
q: str = None
):
query = db.query(User)
if role:
query = query.filter(User.role == role)
if status:
query = query.filter(User.status == status)
if q:
query = query.filter(User.name.contains(q))
return query.all()
Sorting
# Ascending
GET /posts?sort=created_at
# Descending (- prefix)
GET /posts?sort=-created_at
# Multiple sorts
GET /posts?sort=-created_at,title
Implementation:
@app.get("/posts")
def list_posts(sort: str = None):
query = db.query(Post)
if sort:
for field in sort.split(','):
if field.startswith('-'):
# Descending
query = query.order_by(desc(getattr(Post, field[1:])))
else:
# Ascending
query = query.order_by(asc(getattr(Post, field)))
return query.all()
OpenAPI (Swagger) Documentation
Auto-Generated Docs (FastAPI)
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI(
title="My API",
description="API for managing users and posts",
version="1.0.0",
docs_url="/docs", # Swagger UI
redoc_url="/redoc" # ReDoc UI
)
class User(BaseModel):
"""User model"""
id: int = Field(..., description="Unique user ID")
email: str = Field(..., description="User email address")
name: str = Field(..., description="User full name")
@app.get(
"/users/{user_id}",
response_model=User,
summary="Get user by ID",
description="Retrieve a single user by their unique ID",
responses={
200: {"description": "User found"},
404: {"description": "User not found"}
}
)
def get_user(user_id: int):
"""
Get user by ID.
Returns user object if found, 404 if not found.
"""
return db.get_user(user_id)
Auto-generated docs at:
/docs- Swagger UI (interactive)/redoc- ReDoc (pretty)/openapi.json- OpenAPI spec
Idempotency
Idempotency Keys (POST)
Problem: POST requests aren't idempotent (create duplicate resources if retried)
Solution: Idempotency keys
POST /payments
Idempotency-Key: abc123...
{
"amount": 100,
"currency": "USD"
}
Implementation:
import redis
redis_client = redis.Redis()
@app.post("/payments")
def create_payment(
payment: Payment,
idempotency_key: str = Header(...)
):
# Check if we've seen this key before
cached = redis_client.get(f"idempotency:{idempotency_key}")
if cached:
return json.loads(cached)
# Process payment
result = process_payment(payment)
# Cache result for 24 hours
redis_client.setex(
f"idempotency:{idempotency_key}",
86400,
json.dumps(result)
)
return result
Content Negotiation
Client specifies desired format:
GET /users
Accept: application/json # JSON response
GET /users
Accept: application/xml # XML response
Implementation:
from fastapi import Request
@app.get("/users")
def get_users(request: Request):
users = db.get_users()
if "application/xml" in request.headers.get("accept", ""):
return Response(content=to_xml(users), media_type="application/xml")
else:
return users # JSON by default
API Design Checklist
Before shipping an API:
- Nouns for resources (/users, not /getUsers)
- Plural resource names (/users, not /user)
- Proper HTTP methods (GET/POST/PUT/DELETE)
- Proper status codes (200/201/204/400/404/500)
- Consistent error format (RFC 7807 or custom)
- Pagination (for collections)
- Filtering & sorting (query params)
- Versioning (/v1/users)
- Authentication (API key or JWT)
- Rate limiting (protect from abuse)
- CORS (if browser access needed)
- Documentation (OpenAPI/Swagger)
- Idempotency (for payment/critical endpoints)
- Validation (request body validation)
- Security (no secrets in responses)
Common Patterns
HATEOAS (Hypermedia)
Include links to related resources:
GET /users/123
{
"id": 123,
"email": "user@example.com",
"links": {
"self": "/users/123",
"posts": "/users/123/posts",
"followers": "/users/123/followers"
}
}
Bulk Operations
Batch create:
POST /users/batch
{
"users": [
{"email": "user1@example.com"},
{"email": "user2@example.com"}
]
}
Batch update:
PATCH /users/batch
{
"updates": [
{"id": 1, "status": "active"},
{"id": 2, "status": "inactive"}
]
}
Webhooks
Allow clients to subscribe to events:
POST /webhooks
{
"url": "https://example.com/webhook",
"events": ["user.created", "user.updated"]
}
Send events:
import requests
def notify_webhook(event_type: str, data: dict):
webhooks = db.get_webhooks(event_type)
for webhook in webhooks:
requests.post(webhook.url, json={
"event": event_type,
"data": data,
"timestamp": datetime.utcnow().isoformat()
})
# Usage
user = create_user(...)
notify_webhook("user.created", user)
Key Takeaways
- Resources as nouns (/users, not /getUsers)
- Use proper HTTP methods (GET/POST/PUT/DELETE)
- Use proper status codes (200/201/204/400/404)
- Version your API (/v1, /v2)
- Paginate collections (offset or cursor)
- Consistent errors (RFC 7807)
- Authenticate requests (API key or JWT)
- Rate limit (protect from abuse)
- Document with OpenAPI (auto-generate)
- Test idempotency (especially payments)
Version: 1.0.0 Type: Knowledge skill (no scripts) See Also: security-patterns (API security), python-standards (FastAPI), testing-guide (API tests)
More from akaszubski/anyclaude-local
testing-guide
Complete testing methodology - TDD, progression tracking, regression prevention, and test patterns
1observability
Logging, debugging, profiling, and performance monitoring for development. Use when adding logging, debugging issues, profiling performance, or instrumenting code for observability.
1git-workflow
Git best practices, commit conventions, branching strategies, and pull request workflows. Use when working with git operations, commits, branches, or PRs.
1python-standards
Python code quality standards (PEP 8, type hints, docstrings). Use when writing Python code.
1github-workflow
GitHub-first workflow - Issues, PRs, milestones, auto-tracking for solo developer productivity
1code-review
This skill should be used when reviewing code or preparing code for review. It provides guidelines for what to look for in reviews, how to write constructive feedback, and standards for review comments.
1