REST API Design Fundamentals
Learn the core principles of RESTful API design, resource modeling, and HTTP method semantics
Why REST Design Matters
REST (Representational State Transfer) is the architectural style that powers the modern web. Understanding RESTful design principles enables you to build APIs that are intuitive, scalable, and follow industry standards. Whether you're building internal microservices or public APIs, mastering REST design is essential for creating systems that developers love to use.
What is REST?
REST was introduced by Roy Fielding in his 2000 doctoral dissertation. It's an architectural style, not a protocol or standard. REST defines a set of constraints that, when applied to web services, create scalable, maintainable, and performant APIs.
Key Characteristics of REST APIs
- Stateless: Each request contains all information needed to process it
- Client-Server: Clear separation between client and server concerns
- Cacheable: Responses explicitly indicate if they can be cached
- Uniform Interface: Consistent way to interact with resources
- Layered System: Client can't tell if connected directly to server or intermediary
The Six REST Constraints
To be considered RESTful, an API should adhere to these six architectural constraints:
1. Client-Server Architecture
Separation of concerns between the user interface (client) and data storage (server). This allows each to evolve independently.
2. Statelessness
Each request from client to server must contain all information necessary to understand and process the request. The server does not store client context between requests.
# ❌ Stateful (Bad) # Server maintains session data GET /api/next-item # Server remembers where you are in pagination # ✅ Stateless (Good) # Client sends all context GET /api/items?page=2&limit=10 # Server doesn't need to remember anything
3. Cacheability
Responses must define themselves as cacheable or non-cacheable to prevent clients from reusing stale data.
# HTTP Response Headers HTTP/1.1 200 OK Cache-Control: max-age=3600, public ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" # This response can be cached for 1 hour # ETag allows conditional requests to check if data changed
4. Uniform Interface
The most important REST constraint. Defines a standard way to interact with resources:
- Resource identification: Each resource has a unique URI
- Resource manipulation through representations: Use JSON, XML, etc.
- Self-descriptive messages: Each message includes enough info to process it
- Hypermedia (HATEOAS, Hypermedia as the Engine of Application State): Responses include links to related actions
5. Layered System
Client cannot tell whether it's connected directly to the end server or an intermediary. This allows for load balancers, caches, and security layers without client changes.
6. Code on Demand (Optional)
Servers can extend client functionality by transferring executable code (e.g., JavaScript). This is the only optional constraint.
Resource-Oriented Design
In REST, everything is a resource. A resource is any information that can be named: a document, image, collection of items, user, etc. Resources are identified by URIs and manipulated using HTTP methods.
URI Design Principles
GET /getUser?id=123 POST /createUser POST /deleteUser/123
GET /users/123 POST /users DELETE /users/123
| Resource | Collection URI | Single Item URI | Nested Resource |
|---|---|---|---|
| Users | /users | /users/123 | /users/123/posts |
| Products | /products | /products/p-456 | /products/p-456/reviews |
| Orders | /orders | /orders/o-789 | /orders/o-789/items |
URI Design Best Practices
- Use plural nouns for collections:
/usersnot/user - Use lowercase letters and hyphens:
/order-itemsnot/orderItems - Avoid file extensions:
/users/123not/users/123.json - Use query parameters for filtering:
/users?role=admin&status=active - Keep URIs shallow (max 3 levels):
/users/123/postsis good - Use path parameters for resource IDs:
/users/{userId}
HTTP Methods and Semantics
HTTP methods (verbs) define the action to be performed on a resource. Understanding their semantics and properties is crucial for RESTful design.
GET - Retrieve Resources
SafeIdempotent
Retrieves a representation of a resource. GET requests should never modify data.
# Python FastAPI Example
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
# Retrieve user from database
user = await db.get_user(user_id)
return {
"id": user.id,
"name": user.name,
"email": user.email
}
# Usage:
# GET /users/123
# Returns: {"id": 123, "name": "Alice", "email": "alice@example.com"}POST - Create Resources
Not SafeNot Idempotent
Creates a new resource. The server assigns the resource identifier.
from fastapi import FastAPI
from pydantic import BaseModel
class UserCreate(BaseModel):
name: str
email: str
@app.post("/users", status_code=201)
async def create_user(user: UserCreate, response: Response):
# Create new user in database
new_user = await db.create_user(user.name, user.email)
# Set Location header pointing to new resource
response.headers["Location"] = f"/users/{new_user.id}"
return {
"id": new_user.id,
"name": new_user.name,
"email": new_user.email
}
# Request:
# POST /users
# Body: {"name": "Bob", "email": "bob@example.com"}
# Response: 201 Created
# Body: {"id": 456, "name": "Bob", "email": "bob@example.com"}
# Header: Location: /users/456PUT - Update/Replace Resources
Not SafeIdempotent
Replaces the entire resource. Client provides the full representation.
@app.put("/users/{user_id}")
async def update_user(user_id: int, user: UserCreate):
# Replace entire user resource
updated_user = await db.update_user(
user_id,
name=user.name,
email=user.email
)
return updated_user
# Request:
# PUT /users/123
# Body: {"name": "Alice Updated", "email": "alice.new@example.com"}
# Response: 200 OK
# Body: {"id": 123, "name": "Alice Updated", "email": "alice.new@example.com"}PATCH - Partial Update
Not SafeNot Necessarily Idempotent
Applies partial modifications to a resource. Only updates specified fields.
from pydantic import BaseModel
from typing import Optional
class UserPatch(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
@app.patch("/users/{user_id}")
async def patch_user(user_id: int, updates: UserPatch):
# Update only provided fields
user = await db.get_user(user_id)
if updates.name is not None:
user.name = updates.name
if updates.email is not None:
user.email = updates.email
await db.save_user(user)
return user
# Request:
# PATCH /users/123
# Body: {"email": "alice.patched@example.com"}
# Response: 200 OK
# Body: {"id": 123, "name": "Alice", "email": "alice.patched@example.com"}DELETE - Remove Resources
Not SafeIdempotent
Deletes a resource. Subsequent DELETE requests to the same URI should return 404.
@app.delete("/users/{user_id}", status_code=204)
async def delete_user(user_id: int):
# Delete user from database
await db.delete_user(user_id)
return # 204 No Content (empty response)
# Request:
# DELETE /users/123
# Response: 204 No Content
# (No response body)HEAD - Retrieve Headers Only
SafeIdempotent
Identical to GET, but returns only headers without the response body. Useful for checking if a resource exists, getting metadata, or checking cache validity without transferring the full content.
@app.head("/users/{user_id}")
async def head_user(user_id: int):
# Check if user exists
user = await db.get_user(user_id)
if not user:
raise HTTPException(status_code=404)
# Return headers only (FastAPI handles this automatically)
return Response(headers={
"Content-Type": "application/json",
"Content-Length": str(len(json.dumps(user))),
"Last-Modified": user.updated_at.isoformat()
})
# Request:
# HEAD /users/123
# Response: 200 OK
# Content-Type: application/json
# Content-Length: 245
# Last-Modified: 2024-01-15T10:30:00Z
# (No response body)OPTIONS - Describe Communication Options
SafeIdempotent
Returns the HTTP methods supported by a resource. Used for CORS preflight requests and API discovery. The server responds with an Allow header listing available methods.
@app.options("/users/{user_id}")
async def options_user(user_id: int):
return Response(
status_code=204,
headers={
"Allow": "GET, PUT, PATCH, DELETE, HEAD, OPTIONS",
"Access-Control-Allow-Methods": "GET, PUT, PATCH, DELETE",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
}
)
# Request:
# OPTIONS /users/123
# Response: 204 No Content
# Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
# Access-Control-Allow-Methods: GET, PUT, PATCH, DELETE
# Access-Control-Allow-Headers: Content-Type, AuthorizationCONNECT - Establish Network Tunnel
Proxy MethodNot Idempotent
Establishes a tunnel to the server identified by the target resource. Primarily used for HTTPS connections through HTTP proxies. Not typically implemented in REST APIs.
# CONNECT is handled by proxy servers, not typically in REST APIs # Example proxy usage: # Client → Proxy: CONNECT api.example.com:443 HTTP/1.1 Host: api.example.com:443 # Proxy → Client: HTTP/1.1 200 Connection Established # Then client sends HTTPS traffic through the tunnel
TRACE - Message Loop-Back Test
SafeIdempotent
Performs a diagnostic loop-back test along the path to the target resource. The server echoes back the received request so the client can see what was received. Often disabled for security reasons.
# TRACE is typically disabled in production for security # If enabled, it would look like this: # Request: TRACE /users/123 HTTP/1.1 Host: api.example.com Authorization: Bearer token123 # Response: 200 OK # Content-Type: message/http TRACE /users/123 HTTP/1.1 Host: api.example.com Authorization: Bearer token123 # Note: Most servers disable TRACE to prevent XST attacks
| Method | Safe | Idempotent | Cacheable | Purpose |
|---|---|---|---|---|
| GET | ✅ Yes | ✅ Yes | ✅ Yes | Retrieve resource |
| POST | ❌ No | ❌ No | ⚠️ Rarely | Create resource |
| PUT | ❌ No | ✅ Yes | ❌ No | Replace resource |
| PATCH | ❌ No | ⚠️ Maybe | ❌ No | Partial update |
| DELETE | ❌ No | ✅ Yes | ❌ No | Delete resource |
| HEAD | ✅ Yes | ✅ Yes | ✅ Yes | Retrieve headers only |
| OPTIONS | ✅ Yes | ✅ Yes | ❌ No | Describe communication options |
| CONNECT | ❌ No | ❌ No | ❌ No | Establish network tunnel |
| TRACE | ✅ Yes | ✅ Yes | ❌ No | Message loop-back test |
Understanding Idempotency
Idempotent means making the same request multiple times has the same effect as making it once. The outcome is the same, even if the server response might differ.
DELETE /users/123- First call deletes, subsequent calls get 404 (still idempotent)PUT /users/123- Sets user to same state every timePOST /users- Creates a new user each time (NOT idempotent)
HTTP Status Codes Guide
Status codes tell the client what happened with their request. Using the right status code makes your API intuitive and debuggable.
2xx - Success
GET /users/123 → 200 OK (returns user)
Location header with new resource URI.POST /users → 201 Created + Location: /users/456
POST /reports/generate → 202 Accepted (processing in background)
DELETE /users/123 → 204 No Content
4xx - Client Errors
POST /users with invalid JSON → 400
GET /users without auth token → 401
DELETE /users/999 (not your user) → 403
GET /users/99999 → 404
POST /users {email: "invalid"} → 422Retry-After header.GET /api/* (100th request) → 429 + Retry-After: 60
5xx - Server Errors
GET /users (database crash) → 500
GET /api/* (during maintenance) → 503
# FastAPI Status Code Example
from fastapi import FastAPI, HTTPException, status
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await db.get_user(user_id)
if not user:
# Return 404 if user doesn't exist
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found"
)
# Return 200 with user data
return user
# Result: GET /users/999 → 404 with error messageRichardson Maturity Model
Leonard Richardson created a maturity model to classify REST APIs based on how RESTful they are. It has 4 levels (0-3):
Level 0: The Swamp of POX (Plain Old XML)
Uses HTTP as a transport mechanism. Everything is POST to a single endpoint. Think SOAP or XML-RPC.
POST /api
{
"method": "getUser",
"params": {"id": 123}
}
POST /api
{
"method": "createUser",
"params": {"name": "Alice"}
}Level 1: Resources
Introduces resource-based URIs. Different endpoints for different resources, but still uses only POST.
POST /users/123
{
"action": "get"
}
POST /users
{
"action": "create",
"name": "Alice"
}Level 2: HTTP Verbs
Uses HTTP methods correctly (GET, POST, PUT, DELETE) and proper status codes.
GET /users/123
→ 200 OK
POST /users
Body: {"name": "Alice"}
→ 201 Created
DELETE /users/123
→ 204 No ContentLevel 3: Hypermedia Controls (HATEOAS)
Responses include links to related resources and available actions. The API is self-documenting.
GET /users/123
{
"id": 123,
"name": "Alice",
"links": [
{"rel": "self", "href": "/users/123"},
{"rel": "posts", "href": "/users/123/posts"},
{"rel": "delete", "href": "/users/123", "method": "DELETE"}
]
}Common REST Anti-Patterns to Avoid
❌ Verbs in URIs
POST /createUser GET /getUserById/123 POST /updateUserEmail
✅ Use HTTP Methods
POST /users GET /users/123 PATCH /users/123
❌ Returning 200 for Errors
GET /users/999
200 OK
{"error": "User not found"}✅ Use Proper Status Codes
GET /users/999
404 Not Found
{"detail": "User not found"}❌ Inconsistent Naming
/user (singular) /Posts (capitalized) /order_items (snake_case) /productCategories (camelCase)
✅ Consistent Convention
/users (plural, lowercase) /posts (plural, lowercase) /order-items (kebab-case) /product-categories (kebab-case)
❌ Ignoring Caching
GET /users/123 200 OK (no cache headers)
✅ Include Cache Headers
GET /users/123 200 OK Cache-Control: max-age=3600 ETag: "abc123"
Putting It All Together: Complete FastAPI Example
Here's a complete RESTful API for managing blog posts, demonstrating all the principles we've covered.
from fastapi import FastAPI, HTTPException, status, Response
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime
app = FastAPI(title="Blog API", version="1.0.0")
# Data models
class PostCreate(BaseModel):
title: str
content: str
author_email: EmailStr
class PostUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
class Post(BaseModel):
id: int
title: str
content: str
author_email: str
created_at: datetime
updated_at: datetime
# In-memory database (use real database in production)
posts_db = {}
post_id_counter = 1
# GET /posts - List all posts
@app.get("/posts", response_model=List[Post], status_code=200)
async def list_posts(
limit: int = 10,
offset: int = 0,
author: Optional[str] = None
):
"""
Retrieve a list of blog posts with pagination.
- **limit**: Number of posts to return (default: 10)
- **offset**: Number of posts to skip (default: 0)
- **author**: Filter by author email (optional)
"""
all_posts = list(posts_db.values())
# Filter by author if provided
if author:
all_posts = [p for p in all_posts if p["author_email"] == author]
# Apply pagination
paginated = all_posts[offset:offset + limit]
return paginated
# POST /posts - Create a new post
@app.post("/posts", response_model=Post, status_code=201)
async def create_post(post: PostCreate, response: Response):
"""
Create a new blog post.
Returns 201 Created with Location header pointing to new resource.
"""
global post_id_counter
new_post = {
"id": post_id_counter,
"title": post.title,
"content": post.content,
"author_email": post.author_email,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
posts_db[post_id_counter] = new_post
# Set Location header
response.headers["Location"] = f"/posts/{post_id_counter}"
post_id_counter += 1
return new_post
# GET /posts/{post_id} - Get single post
@app.get("/posts/{post_id}", response_model=Post, status_code=200)
async def get_post(post_id: int):
"""
Retrieve a specific blog post by ID.
Returns 404 if post doesn't exist.
"""
if post_id not in posts_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post {post_id} not found"
)
return posts_db[post_id]
# PUT /posts/{post_id} - Replace entire post
@app.put("/posts/{post_id}", response_model=Post, status_code=200)
async def replace_post(post_id: int, post: PostCreate):
"""
Replace an entire blog post.
All fields must be provided. Returns 404 if post doesn't exist.
"""
if post_id not in posts_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post {post_id} not found"
)
# Keep original created_at, update everything else
updated_post = {
"id": post_id,
"title": post.title,
"content": post.content,
"author_email": post.author_email,
"created_at": posts_db[post_id]["created_at"],
"updated_at": datetime.now()
}
posts_db[post_id] = updated_post
return updated_post
# PATCH /posts/{post_id} - Partial update
@app.patch("/posts/{post_id}", response_model=Post, status_code=200)
async def update_post(post_id: int, updates: PostUpdate):
"""
Partially update a blog post.
Only provided fields are updated. Returns 404 if post doesn't exist.
"""
if post_id not in posts_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post {post_id} not found"
)
post = posts_db[post_id]
# Update only provided fields
if updates.title is not None:
post["title"] = updates.title
if updates.content is not None:
post["content"] = updates.content
post["updated_at"] = datetime.now()
posts_db[post_id] = post
return post
# DELETE /posts/{post_id} - Delete post
@app.delete("/posts/{post_id}", status_code=204)
async def delete_post(post_id: int):
"""
Delete a blog post.
Returns 204 No Content on success, 404 if post doesn't exist.
"""
if post_id not in posts_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post {post_id} not found"
)
del posts_db[post_id]
return Response(status_code=status.HTTP_204_NO_CONTENT)
# Run with: uvicorn main:app --reloadWhat Makes This RESTful?
- ✅ Resource-based URIs:
/postsand/posts/{post_id} - ✅ HTTP methods used correctly: GET, POST, PUT, PATCH, DELETE
- ✅ Proper status codes: 200, 201, 204, 404
- ✅ Stateless: Each request is self-contained
- ✅ Consistent naming: Plural nouns, lowercase
- ✅ Query parameters for filtering:
?author=email@example.com - ✅ Location header: Points to newly created resource
Key Takeaways
- REST is an architectural style, not a strict protocol. Follow principles, not rigid rules.
- Resources are nouns, actions are HTTP methods. URIs identify what, methods specify how.
- Statelessness is crucial for scalability. Each request contains all needed information.
- Status codes matter. They're the first thing developers check when debugging.
- Idempotency enables safe retries. GET, PUT, and DELETE should be idempotent.
- Consistency beats perfection. Pick conventions and stick to them across your entire API.