Skip to content

API Design

Build APIs that are consistent, documented, and evolvable.

Design Principles

1. Consistency

Use the same patterns everywhere:

GET    /resources          → List resources
GET    /resources/{id}     → Get single resource
POST   /resources          → Create resource
PUT    /resources/{id}     → Replace resource
PATCH  /resources/{id}     → Partial update
DELETE /resources/{id}     → Delete resource

2. Predictability

Users should guess correctly how your API works:

# Consistent naming
GET /users/{user_id}/posts      # User's posts
GET /posts/{post_id}/comments   # Post's comments

# Consistent filtering
GET /posts?status=published&author_id=123
GET /users?role=admin&active=true

# Consistent pagination
GET /posts?page=2&per_page=20
GET /users?page=2&per_page=20

3. Discoverability

Make the API self-documenting:

{
  "data": {...},
  "links": {
    "self": "/posts/123",
    "author": "/users/456",
    "comments": "/posts/123/comments"
  }
}

Section Contents

Topic Description
OpenAPI FastAPI OpenAPI generation
Versioning API versioning strategies
Backwards Compatibility Non-breaking changes
Error Contracts Consistent error responses
Documentation API docs best practices

Quick Reference

HTTP Methods

Method Idempotent Safe Use Case
GET Yes Yes Read resources
POST No No Create resources
PUT Yes No Replace resources
PATCH No No Partial update
DELETE Yes No Delete resources

Status Codes

Code Meaning When to Use
200 OK Successful GET, PUT, PATCH
201 Created Successful POST
204 No Content Successful DELETE
400 Bad Request Invalid input
401 Unauthorized No/invalid auth
403 Forbidden Auth valid, not permitted
404 Not Found Resource doesn't exist
409 Conflict Duplicate, version conflict
422 Unprocessable Validation failed
429 Too Many Requests Rate limited
500 Server Error Unexpected error

Response Structure

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

T = TypeVar("T")

class PaginationMeta(BaseModel):
    page: int
    per_page: int
    total: int
    total_pages: int

class Links(BaseModel):
    self: str
    next: Optional[str] = None
    prev: Optional[str] = None

class ApiResponse(BaseModel, Generic[T]):
    data: T
    meta: Optional[dict] = None
    links: Optional[Links] = None

class PaginatedResponse(BaseModel, Generic[T]):
    data: List[T]
    meta: PaginationMeta
    links: Links

Example Endpoints

from fastapi import FastAPI, Query, Path, HTTPException
from typing import List

app = FastAPI()

@app.get("/posts", response_model=PaginatedResponse[Post])
async def list_posts(
    page: int = Query(1, ge=1),
    per_page: int = Query(20, ge=1, le=100),
    status: Optional[str] = Query(None, enum=["draft", "published"]),
    author_id: Optional[int] = None,
):
    """List posts with filtering and pagination."""
    pass

@app.get("/posts/{post_id}", response_model=ApiResponse[Post])
async def get_post(
    post_id: int = Path(..., ge=1),
):
    """Get a single post by ID."""
    pass

@app.post("/posts", response_model=ApiResponse[Post], status_code=201)
async def create_post(
    post: PostCreate,
):
    """Create a new post."""
    pass

@app.patch("/posts/{post_id}", response_model=ApiResponse[Post])
async def update_post(
    post_id: int,
    post: PostUpdate,
):
    """Partially update a post."""
    pass

@app.delete("/posts/{post_id}", status_code=204)
async def delete_post(
    post_id: int,
):
    """Delete a post."""
    pass

URL Design

Good URLs

GET  /users/123/posts          # User's posts
GET  /posts?author_id=123      # Filter by author
GET  /posts/recent             # Named query
POST /posts/123/publish        # Action on resource

Bad URLs

GET  /getUserPosts?userId=123  # Verb in URL
GET  /posts/getRecent          # Verb in URL
POST /posts/123?action=publish # Action in query
GET  /Posts/123                # Inconsistent casing

Nesting Guidelines

# Good: One level of nesting
GET /users/123/posts

# Avoid: Deep nesting
GET /users/123/posts/456/comments/789/likes

# Better: Direct access with filtering
GET /comments/789/likes
GET /comments?post_id=456

Filtering & Sorting

Query Parameters

@app.get("/posts")
async def list_posts(
    # Filtering
    status: Optional[str] = None,
    author_id: Optional[int] = None,
    created_after: Optional[datetime] = None,
    created_before: Optional[datetime] = None,

    # Sorting
    sort: str = Query("created_at", regex="^-?(created_at|title|views)$"),

    # Pagination
    page: int = Query(1, ge=1),
    per_page: int = Query(20, ge=1, le=100),
):
    # Parse sort direction
    desc = sort.startswith("-")
    sort_field = sort.lstrip("-")

    query = select(Post)

    if status:
        query = query.where(Post.status == status)
    if author_id:
        query = query.where(Post.author_id == author_id)
    if created_after:
        query = query.where(Post.created_at >= created_after)

    if desc:
        query = query.order_by(getattr(Post, sort_field).desc())
    else:
        query = query.order_by(getattr(Post, sort_field))

    # Paginate
    total = await count_query(query)
    query = query.offset((page - 1) * per_page).limit(per_page)

    posts = await session.execute(query)
    return paginated_response(posts, page, per_page, total)

Request/Response Examples

Create Resource

POST /api/v1/posts HTTP/1.1
Content-Type: application/json

{
  "title": "My Post",
  "content": "Post content here",
  "tags": ["python", "api"]
}
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/posts/123

{
  "data": {
    "id": 123,
    "title": "My Post",
    "content": "Post content here",
    "tags": ["python", "api"],
    "status": "draft",
    "created_at": "2024-01-15T10:30:00Z"
  },
  "links": {
    "self": "/api/v1/posts/123"
  }
}

List with Pagination

GET /api/v1/posts?page=2&per_page=10&status=published HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": [
    {"id": 11, "title": "Post 11", ...},
    {"id": 12, "title": "Post 12", ...}
  ],
  "meta": {
    "page": 2,
    "per_page": 10,
    "total": 45,
    "total_pages": 5
  },
  "links": {
    "self": "/api/v1/posts?page=2&per_page=10&status=published",
    "next": "/api/v1/posts?page=3&per_page=10&status=published",
    "prev": "/api/v1/posts?page=1&per_page=10&status=published"
  }
}