528 lines
13 KiB
Markdown
528 lines
13 KiB
Markdown
---
|
|
name: api-design-principles
|
|
description: Master REST and GraphQL API design principles to build intuitive, scalable, and maintainable APIs that delight developers. Use when designing new APIs, reviewing API specifications, or establishing API design standards.
|
|
---
|
|
|
|
# API Design Principles
|
|
|
|
Master REST and GraphQL API design principles to build intuitive, scalable, and maintainable APIs that delight developers and stand the test of time.
|
|
|
|
## When to Use This Skill
|
|
|
|
- Designing new REST or GraphQL APIs
|
|
- Refactoring existing APIs for better usability
|
|
- Establishing API design standards for your team
|
|
- Reviewing API specifications before implementation
|
|
- Migrating between API paradigms (REST to GraphQL, etc.)
|
|
- Creating developer-friendly API documentation
|
|
- Optimizing APIs for specific use cases (mobile, third-party integrations)
|
|
|
|
## Core Concepts
|
|
|
|
### 1. RESTful Design Principles
|
|
|
|
**Resource-Oriented Architecture**
|
|
- Resources are nouns (users, orders, products), not verbs
|
|
- Use HTTP methods for actions (GET, POST, PUT, PATCH, DELETE)
|
|
- URLs represent resource hierarchies
|
|
- Consistent naming conventions
|
|
|
|
**HTTP Methods Semantics:**
|
|
- `GET`: Retrieve resources (idempotent, safe)
|
|
- `POST`: Create new resources
|
|
- `PUT`: Replace entire resource (idempotent)
|
|
- `PATCH`: Partial resource updates
|
|
- `DELETE`: Remove resources (idempotent)
|
|
|
|
### 2. GraphQL Design Principles
|
|
|
|
**Schema-First Development**
|
|
- Types define your domain model
|
|
- Queries for reading data
|
|
- Mutations for modifying data
|
|
- Subscriptions for real-time updates
|
|
|
|
**Query Structure:**
|
|
- Clients request exactly what they need
|
|
- Single endpoint, multiple operations
|
|
- Strongly typed schema
|
|
- Introspection built-in
|
|
|
|
### 3. API Versioning Strategies
|
|
|
|
**URL Versioning:**
|
|
```
|
|
/api/v1/users
|
|
/api/v2/users
|
|
```
|
|
|
|
**Header Versioning:**
|
|
```
|
|
Accept: application/vnd.api+json; version=1
|
|
```
|
|
|
|
**Query Parameter Versioning:**
|
|
```
|
|
/api/users?version=1
|
|
```
|
|
|
|
## REST API Design Patterns
|
|
|
|
### Pattern 1: Resource Collection Design
|
|
|
|
```python
|
|
# Good: Resource-oriented endpoints
|
|
GET /api/users # List users (with pagination)
|
|
POST /api/users # Create user
|
|
GET /api/users/{id} # Get specific user
|
|
PUT /api/users/{id} # Replace user
|
|
PATCH /api/users/{id} # Update user fields
|
|
DELETE /api/users/{id} # Delete user
|
|
|
|
# Nested resources
|
|
GET /api/users/{id}/orders # Get user's orders
|
|
POST /api/users/{id}/orders # Create order for user
|
|
|
|
# Bad: Action-oriented endpoints (avoid)
|
|
POST /api/createUser
|
|
POST /api/getUserById
|
|
POST /api/deleteUser
|
|
```
|
|
|
|
### Pattern 2: Pagination and Filtering
|
|
|
|
```python
|
|
from typing import List, Optional
|
|
from pydantic import BaseModel, Field
|
|
|
|
class PaginationParams(BaseModel):
|
|
page: int = Field(1, ge=1, description="Page number")
|
|
page_size: int = Field(20, ge=1, le=100, description="Items per page")
|
|
|
|
class FilterParams(BaseModel):
|
|
status: Optional[str] = None
|
|
created_after: Optional[str] = None
|
|
search: Optional[str] = None
|
|
|
|
class PaginatedResponse(BaseModel):
|
|
items: List[dict]
|
|
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
|
|
|
|
# FastAPI endpoint example
|
|
from fastapi import FastAPI, Query, Depends
|
|
|
|
app = FastAPI()
|
|
|
|
@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)
|
|
):
|
|
# Apply filters
|
|
query = build_query(status=status, search=search)
|
|
|
|
# Count total
|
|
total = await count_users(query)
|
|
|
|
# Fetch page
|
|
offset = (page - 1) * page_size
|
|
users = await fetch_users(query, limit=page_size, offset=offset)
|
|
|
|
return PaginatedResponse(
|
|
items=users,
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
pages=(total + page_size - 1) // page_size
|
|
)
|
|
```
|
|
|
|
### Pattern 3: Error Handling and Status Codes
|
|
|
|
```python
|
|
from fastapi import HTTPException, status
|
|
from pydantic import BaseModel
|
|
|
|
class ErrorResponse(BaseModel):
|
|
error: str
|
|
message: str
|
|
details: Optional[dict] = None
|
|
timestamp: str
|
|
path: str
|
|
|
|
class ValidationErrorDetail(BaseModel):
|
|
field: str
|
|
message: str
|
|
value: Any
|
|
|
|
# Consistent error responses
|
|
STATUS_CODES = {
|
|
"success": 200,
|
|
"created": 201,
|
|
"no_content": 204,
|
|
"bad_request": 400,
|
|
"unauthorized": 401,
|
|
"forbidden": 403,
|
|
"not_found": 404,
|
|
"conflict": 409,
|
|
"unprocessable": 422,
|
|
"internal_error": 500
|
|
}
|
|
|
|
def raise_not_found(resource: str, id: str):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={
|
|
"error": "NotFound",
|
|
"message": f"{resource} not found",
|
|
"details": {"id": id}
|
|
}
|
|
)
|
|
|
|
def raise_validation_error(errors: List[ValidationErrorDetail]):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail={
|
|
"error": "ValidationError",
|
|
"message": "Request validation failed",
|
|
"details": {"errors": [e.dict() for e in errors]}
|
|
}
|
|
)
|
|
|
|
# Example usage
|
|
@app.get("/api/users/{user_id}")
|
|
async def get_user(user_id: str):
|
|
user = await fetch_user(user_id)
|
|
if not user:
|
|
raise_not_found("User", user_id)
|
|
return user
|
|
```
|
|
|
|
### Pattern 4: HATEOAS (Hypermedia as the Engine of Application State)
|
|
|
|
```python
|
|
class UserResponse(BaseModel):
|
|
id: str
|
|
name: str
|
|
email: str
|
|
_links: dict
|
|
|
|
@classmethod
|
|
def from_user(cls, user: User, base_url: str):
|
|
return cls(
|
|
id=user.id,
|
|
name=user.name,
|
|
email=user.email,
|
|
_links={
|
|
"self": {"href": f"{base_url}/api/users/{user.id}"},
|
|
"orders": {"href": f"{base_url}/api/users/{user.id}/orders"},
|
|
"update": {
|
|
"href": f"{base_url}/api/users/{user.id}",
|
|
"method": "PATCH"
|
|
},
|
|
"delete": {
|
|
"href": f"{base_url}/api/users/{user.id}",
|
|
"method": "DELETE"
|
|
}
|
|
}
|
|
)
|
|
```
|
|
|
|
## GraphQL Design Patterns
|
|
|
|
### Pattern 1: Schema Design
|
|
|
|
```graphql
|
|
# schema.graphql
|
|
|
|
# Clear type definitions
|
|
type User {
|
|
id: ID!
|
|
email: String!
|
|
name: String!
|
|
createdAt: DateTime!
|
|
|
|
# Relationships
|
|
orders(
|
|
first: Int = 20
|
|
after: String
|
|
status: OrderStatus
|
|
): OrderConnection!
|
|
|
|
profile: UserProfile
|
|
}
|
|
|
|
type Order {
|
|
id: ID!
|
|
status: OrderStatus!
|
|
total: Money!
|
|
items: [OrderItem!]!
|
|
createdAt: DateTime!
|
|
|
|
# Back-reference
|
|
user: User!
|
|
}
|
|
|
|
# Pagination pattern (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
|
|
}
|
|
|
|
# Enums for type safety
|
|
enum OrderStatus {
|
|
PENDING
|
|
CONFIRMED
|
|
SHIPPED
|
|
DELIVERED
|
|
CANCELLED
|
|
}
|
|
|
|
# Custom scalars
|
|
scalar DateTime
|
|
scalar Money
|
|
|
|
# Query root
|
|
type Query {
|
|
user(id: ID!): User
|
|
users(
|
|
first: Int = 20
|
|
after: String
|
|
search: String
|
|
): UserConnection!
|
|
|
|
order(id: ID!): Order
|
|
}
|
|
|
|
# Mutation root
|
|
type Mutation {
|
|
createUser(input: CreateUserInput!): CreateUserPayload!
|
|
updateUser(input: UpdateUserInput!): UpdateUserPayload!
|
|
deleteUser(id: ID!): DeleteUserPayload!
|
|
|
|
createOrder(input: CreateOrderInput!): CreateOrderPayload!
|
|
}
|
|
|
|
# Input types for mutations
|
|
input CreateUserInput {
|
|
email: String!
|
|
name: String!
|
|
password: String!
|
|
}
|
|
|
|
# Payload types for mutations
|
|
type CreateUserPayload {
|
|
user: User
|
|
errors: [Error!]
|
|
}
|
|
|
|
type Error {
|
|
field: String
|
|
message: String!
|
|
}
|
|
```
|
|
|
|
### Pattern 2: Resolver Design
|
|
|
|
```python
|
|
from typing import Optional, List
|
|
from ariadne import QueryType, MutationType, ObjectType
|
|
from dataclasses import dataclass
|
|
|
|
query = QueryType()
|
|
mutation = MutationType()
|
|
user_type = ObjectType("User")
|
|
|
|
@query.field("user")
|
|
async def resolve_user(obj, info, id: str) -> Optional[dict]:
|
|
"""Resolve single user by ID."""
|
|
return await fetch_user_by_id(id)
|
|
|
|
@query.field("users")
|
|
async def resolve_users(
|
|
obj,
|
|
info,
|
|
first: int = 20,
|
|
after: Optional[str] = None,
|
|
search: Optional[str] = None
|
|
) -> dict:
|
|
"""Resolve paginated user list."""
|
|
# Decode cursor
|
|
offset = decode_cursor(after) if after else 0
|
|
|
|
# Fetch users
|
|
users = await fetch_users(
|
|
limit=first + 1, # Fetch one extra to check hasNextPage
|
|
offset=offset,
|
|
search=search
|
|
)
|
|
|
|
# Pagination
|
|
has_next = len(users) > first
|
|
if has_next:
|
|
users = users[:first]
|
|
|
|
edges = [
|
|
{
|
|
"node": user,
|
|
"cursor": encode_cursor(offset + i)
|
|
}
|
|
for i, user in enumerate(users)
|
|
]
|
|
|
|
return {
|
|
"edges": edges,
|
|
"pageInfo": {
|
|
"hasNextPage": has_next,
|
|
"hasPreviousPage": offset > 0,
|
|
"startCursor": edges[0]["cursor"] if edges else None,
|
|
"endCursor": edges[-1]["cursor"] if edges else None
|
|
},
|
|
"totalCount": await count_users(search=search)
|
|
}
|
|
|
|
@user_type.field("orders")
|
|
async def resolve_user_orders(user: dict, info, first: int = 20) -> dict:
|
|
"""Resolve user's orders (N+1 prevention with DataLoader)."""
|
|
# Use DataLoader to batch requests
|
|
loader = info.context["loaders"]["orders_by_user"]
|
|
orders = await loader.load(user["id"])
|
|
|
|
return paginate_orders(orders, first)
|
|
|
|
@mutation.field("createUser")
|
|
async def resolve_create_user(obj, info, input: dict) -> dict:
|
|
"""Create new user."""
|
|
try:
|
|
# Validate input
|
|
validate_user_input(input)
|
|
|
|
# Create user
|
|
user = await create_user(
|
|
email=input["email"],
|
|
name=input["name"],
|
|
password=hash_password(input["password"])
|
|
)
|
|
|
|
return {
|
|
"user": user,
|
|
"errors": []
|
|
}
|
|
except ValidationError as e:
|
|
return {
|
|
"user": None,
|
|
"errors": [{"field": e.field, "message": e.message}]
|
|
}
|
|
```
|
|
|
|
### Pattern 3: DataLoader (N+1 Problem Prevention)
|
|
|
|
```python
|
|
from aiodataloader import DataLoader
|
|
from typing import List, Optional
|
|
|
|
class UserLoader(DataLoader):
|
|
"""Batch load users by ID."""
|
|
|
|
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)
|
|
|
|
# Map results back to input order
|
|
user_map = {user["id"]: user for user in users}
|
|
return [user_map.get(user_id) for user_id in user_ids]
|
|
|
|
class OrdersByUserLoader(DataLoader):
|
|
"""Batch load orders by user ID."""
|
|
|
|
async def batch_load_fn(self, user_ids: List[str]) -> List[List[dict]]:
|
|
"""Load orders for multiple users in single query."""
|
|
orders = await fetch_orders_by_user_ids(user_ids)
|
|
|
|
# Group orders by user_id
|
|
orders_by_user = {}
|
|
for order in orders:
|
|
user_id = order["user_id"]
|
|
if user_id not in orders_by_user:
|
|
orders_by_user[user_id] = []
|
|
orders_by_user[user_id].append(order)
|
|
|
|
# Return in input order
|
|
return [orders_by_user.get(user_id, []) for user_id in user_ids]
|
|
|
|
# Context setup
|
|
def create_context():
|
|
return {
|
|
"loaders": {
|
|
"user": UserLoader(),
|
|
"orders_by_user": OrdersByUserLoader()
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### REST APIs
|
|
1. **Consistent Naming**: Use plural nouns for collections (`/users`, not `/user`)
|
|
2. **Stateless**: Each request contains all necessary information
|
|
3. **Use HTTP Status Codes Correctly**: 2xx success, 4xx client errors, 5xx server errors
|
|
4. **Version Your API**: Plan for breaking changes from day one
|
|
5. **Pagination**: Always paginate large collections
|
|
6. **Rate Limiting**: Protect your API with rate limits
|
|
7. **Documentation**: Use OpenAPI/Swagger for interactive docs
|
|
|
|
### GraphQL APIs
|
|
1. **Schema First**: Design schema before writing resolvers
|
|
2. **Avoid N+1**: Use DataLoaders for efficient data fetching
|
|
3. **Input Validation**: Validate at schema and resolver levels
|
|
4. **Error Handling**: Return structured errors in mutation payloads
|
|
5. **Pagination**: Use cursor-based pagination (Relay spec)
|
|
6. **Deprecation**: Use `@deprecated` directive for gradual migration
|
|
7. **Monitoring**: Track query complexity and execution time
|
|
|
|
## Common Pitfalls
|
|
|
|
- **Over-fetching/Under-fetching (REST)**: Fixed in GraphQL but requires DataLoaders
|
|
- **Breaking Changes**: Version APIs or use deprecation strategies
|
|
- **Inconsistent Error Formats**: Standardize error responses
|
|
- **Missing Rate Limits**: APIs without limits are vulnerable to abuse
|
|
- **Poor Documentation**: Undocumented APIs frustrate developers
|
|
- **Ignoring HTTP Semantics**: POST for idempotent operations breaks expectations
|
|
- **Tight Coupling**: API structure shouldn't mirror database schema
|
|
|
|
## Resources
|
|
|
|
- **references/rest-best-practices.md**: Comprehensive REST API design guide
|
|
- **references/graphql-schema-design.md**: GraphQL schema patterns and anti-patterns
|
|
- **references/api-versioning-strategies.md**: Versioning approaches and migration paths
|
|
- **assets/rest-api-template.py**: FastAPI REST API template
|
|
- **assets/graphql-schema-template.graphql**: Complete GraphQL schema example
|
|
- **assets/api-design-checklist.md**: Pre-implementation review checklist
|
|
- **scripts/openapi-generator.py**: Generate OpenAPI specs from code
|