Back to Blog
REST API Design Best Practices: Building APIs That Developers Love

REST API Design Best Practices: Building APIs That Developers Love

December 12, 2024
8 min read
Tushar Agrawal

Learn how to design clean, scalable, and developer-friendly REST APIs. Covers URL structure, HTTP methods, status codes, pagination, versioning, error handling, and security best practices.

Introduction

A well-designed API is a joy to work with. A poorly designed one causes frustration, bugs, and wasted developer hours. Having built APIs consumed by mobile apps, third-party integrations, and internal services at Dr. Dangs Lab, I've learned what separates great APIs from mediocre ones.

RESTful URL Design

Resource Naming Conventions

Good: Nouns, plural, lowercase, hyphens for multi-word

GET /users GET /users/123 GET /users/123/orders GET /lab-results GET /medical-records

Bad: Verbs, singular, camelCase, underscores

GET /getUsers GET /user/123 GET /get_lab_results POST /createOrder

URL Structure Patterns

Collection

GET /users # List all users POST /users # Create a user

Single resource

GET /users/123 # Get user 123 PUT /users/123 # Replace user 123 PATCH /users/123 # Partial update user 123 DELETE /users/123 # Delete user 123

Nested resources (parent-child relationship)

GET /users/123/orders # User's orders POST /users/123/orders # Create order for user GET /users/123/orders/456 # Specific order

Related resources (use query params for filtering)

GET /orders?user_id=123 # Orders filtered by user

Actions (when CRUD doesn't fit)

POST /orders/123/cancel # Cancel an order POST /users/123/verify-email # Trigger email verification

HTTP Methods and Status Codes

HTTP Methods

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI()

class UserCreate(BaseModel): email: str name: str

class UserUpdate(BaseModel): name: str | None = None email: str | None = None

GET - Retrieve resource (idempotent, safe)

@app.get("/users/{user_id}") async def get_user(user_id: int): user = await db.get_user(user_id) if not user: raise HTTPException(status_code=404, detail="User not found") return user

POST - Create resource (not idempotent)

@app.post("/users", status_code=status.HTTP_201_CREATED) async def create_user(user: UserCreate): new_user = await db.create_user(user) return new_user

PUT - Replace entire resource (idempotent)

@app.put("/users/{user_id}") async def replace_user(user_id: int, user: UserCreate): updated = await db.replace_user(user_id, user) if not updated: raise HTTPException(status_code=404, detail="User not found") return updated

PATCH - Partial update (idempotent)

@app.patch("/users/{user_id}") async def update_user(user_id: int, user: UserUpdate): # Only update provided fields update_data = user.model_dump(exclude_unset=True) updated = await db.update_user(user_id, update_data) return updated

DELETE - Remove resource (idempotent)

@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user(user_id: int): deleted = await db.delete_user(user_id) if not deleted: raise HTTPException(status_code=404, detail="User not found")

Status Codes Cheat Sheet

2xx Success

200 OK # GET, PUT, PATCH successful 201 Created # POST successful, resource created 204 No Content # DELETE successful, no body returned

3xx Redirection

301 Moved Permanently # Resource URL changed permanently 304 Not Modified # Cached response still valid

4xx Client Errors

400 Bad Request # Invalid request syntax/data 401 Unauthorized # Authentication required 403 Forbidden # Authenticated but not authorized 404 Not Found # Resource doesn't exist 405 Method Not Allowed # HTTP method not supported 409 Conflict # Resource state conflict (e.g., duplicate) 422 Unprocessable Entity # Validation failed 429 Too Many Requests # Rate limit exceeded

5xx Server Errors

500 Internal Server Error # Unexpected server error 502 Bad Gateway # Upstream service error 503 Service Unavailable # Server temporarily unavailable 504 Gateway Timeout # Upstream service timeout

Request and Response Design

Consistent Response Format

from pydantic import BaseModel
from typing import Generic, TypeVar, List, Optional
from datetime import datetime

T = TypeVar('T')

class APIResponse(BaseModel, Generic[T]): success: bool data: T message: Optional[str] = None timestamp: datetime = datetime.utcnow()

class PaginatedResponse(BaseModel, Generic[T]): success: bool = True data: List[T] pagination: dict timestamp: datetime = datetime.utcnow()

Usage

@app.get("/users/{user_id}", response_model=APIResponse[User]) async def get_user(user_id: int): user = await db.get_user(user_id) return APIResponse(success=True, data=user)

Response

{ "success": true, "data": { "id": 123, "email": "john@example.com", "name": "John Doe" }, "message": null, "timestamp": "2024-12-12T10:30:00Z" }

Error Response Format

from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import List, Optional

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

class ErrorResponse(BaseModel): success: bool = False error: str details: Optional[List[ErrorDetail]] = None request_id: str timestamp: datetime

@app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): return JSONResponse( status_code=exc.status_code, content={ "success": False, "error": exc.detail, "request_id": request.state.request_id, "timestamp": datetime.utcnow().isoformat() } )

Validation error response

{ "success": false, "error": "Validation failed", "details": [ { "field": "email", "message": "Invalid email format", "code": "INVALID_EMAIL" }, { "field": "age", "message": "Must be at least 18", "code": "MIN_VALUE" } ], "request_id": "req_abc123", "timestamp": "2024-12-12T10:30:00Z" }

Pagination

Cursor-Based Pagination (Recommended)

from fastapi import Query
from typing import Optional
import base64
import json

@app.get("/users") async def list_users( limit: int = Query(default=20, le=100), cursor: Optional[str] = None ): # Decode cursor if cursor: cursor_data = json.loads(base64.b64decode(cursor)) after_id = cursor_data["id"] after_date = cursor_data["created_at"] else: after_id = 0 after_date = None

# Fetch data users = await db.get_users( limit=limit + 1, # Fetch one extra to check if more exist after_id=after_id )

has_more = len(users) > limit if has_more: users = users[:limit]

# Create next cursor next_cursor = None if has_more and users: last_user = users[-1] cursor_data = {"id": last_user.id, "created_at": last_user.created_at.isoformat()} next_cursor = base64.b64encode(json.dumps(cursor_data).encode()).decode()

return { "success": True, "data": users, "pagination": { "limit": limit, "has_more": has_more, "next_cursor": next_cursor } }

Offset-Based Pagination (Simple)

@app.get("/users")
async def list_users(
    page: int = Query(default=1, ge=1),
    per_page: int = Query(default=20, le=100)
):
    offset = (page - 1) * per_page
    users = await db.get_users(limit=per_page, offset=offset)
    total = await db.count_users()

return { "success": True, "data": users, "pagination": { "page": page, "per_page": per_page, "total": total, "total_pages": (total + per_page - 1) // per_page, "has_next": page * per_page < total, "has_prev": page > 1 } }

Filtering, Sorting, and Field Selection

@app.get("/orders")
async def list_orders(
    # Filtering
    status: Optional[str] = Query(None, description="Filter by status"),
    user_id: Optional[int] = Query(None, description="Filter by user"),
    min_amount: Optional[float] = Query(None, description="Minimum order amount"),
    max_amount: Optional[float] = Query(None, description="Maximum order amount"),
    created_after: Optional[datetime] = Query(None, description="Created after date"),

# Sorting sort_by: str = Query("created_at", description="Field to sort by"), sort_order: str = Query("desc", regex="^(asc|desc)$"),

# Field selection fields: Optional[str] = Query(None, description="Comma-separated fields to return"),

# Pagination page: int = Query(1, ge=1), per_page: int = Query(20, le=100) ): # Build query with filters filters = {} if status: filters["status"] = status if user_id: filters["user_id"] = user_id if min_amount: filters["amount__gte"] = min_amount if max_amount: filters["amount__lte"] = max_amount

# Parse fields selected_fields = fields.split(",") if fields else None

orders = await db.get_orders( filters=filters, sort_by=sort_by, sort_order=sort_order, fields=selected_fields, limit=per_page, offset=(page - 1) * per_page )

return {"success": True, "data": orders}

Usage examples:

GET /orders?status=pending&sort_by=amount&sort_order=desc

GET /orders?min_amount=100&max_amount=500&fields=id,status,amount

GET /orders?created_after=2024-01-01T00:00:00Z&user_id=123

API Versioning

from fastapi import APIRouter

URL versioning (most common)

v1_router = APIRouter(prefix="/api/v1") v2_router = APIRouter(prefix="/api/v2")

@v1_router.get("/users/{user_id}") async def get_user_v1(user_id: int): return {"id": user_id, "name": "John"} # Old format

@v2_router.get("/users/{user_id}") async def get_user_v2(user_id: int): return { "data": {"id": user_id, "name": "John"}, "meta": {"version": "2.0"} } # New format with envelope

app.include_router(v1_router) app.include_router(v2_router)

Header versioning (alternative)

@app.get("/users/{user_id}") async def get_user(user_id: int, api_version: str = Header(default="1.0")): if api_version == "2.0": return {"data": {...}, "meta": {...}} return {...} # v1 format

Authentication and Security

from fastapi import Depends, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt

security = HTTPBearer()

async def get_current_user( credentials: HTTPAuthorizationCredentials = Security(security) ): token = credentials.credentials try: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) user_id = payload.get("sub") if not user_id: raise HTTPException(status_code=401, detail="Invalid token") return await db.get_user(user_id) except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.JWTError: raise HTTPException(status_code=401, detail="Invalid token")

Protected endpoint

@app.get("/users/me") async def get_current_user_profile(user: User = Depends(get_current_user)): return user

Rate limiting

from slowapi import Limiter from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

@app.get("/api/resource") @limiter.limit("100/minute") async def rate_limited_endpoint(request: Request): return {"data": "This endpoint is rate limited"}

Documentation with OpenAPI

from fastapi import FastAPI

app = FastAPI( title="Healthcare API", description="API for managing patient data and lab results", version="2.0.0", contact={ "name": "Tushar Agrawal", "email": "tusharagrawal0104@gmail.com" }, license_info={ "name": "MIT", "url": "https://opensource.org/licenses/MIT" } )

@app.get( "/patients/{patient_id}", summary="Get patient by ID", description="Retrieve detailed information about a specific patient", response_description="Patient details", responses={ 200: {"description": "Patient found"}, 404: {"description": "Patient not found"}, 401: {"description": "Not authenticated"} }, tags=["Patients"] ) async def get_patient(patient_id: int): """ Get a patient by their ID.

- patient_id: The unique identifier of the patient """ pass

Key Takeaways

1. Use nouns, not verbs in URLs - let HTTP methods convey the action 2. Be consistent with naming, casing, and response formats 3. Use proper status codes - they communicate intent clearly 4. Implement pagination - never return unbounded lists 5. Version your API - breaking changes will happen 6. Document thoroughly - OpenAPI/Swagger is your friend 7. Handle errors gracefully - descriptive errors help debugging 8. Secure by default - authentication, rate limiting, input validation

Conclusion

Great API design is about empathy for the developers who will use it. Follow conventions, be consistent, document well, and handle edge cases gracefully. Your future self (and your API consumers) will thank you.

---

Building APIs? Let's discuss best practices on LinkedIn.

Related Articles

Share this article

Related Articles