# API Design & Contracts

## When Clients Started Breaking

Three months into production, I needed to add a field to the order response. Simple change, right?

```python
# Before
class OrderResponse(BaseModel):
    id: int
    total: float
    status: str

# After - Added items field
class OrderResponse(BaseModel):
    id: int
    total: float
    status: str
    items: List[OrderItem]  # New field
```

I deployed. Within minutes, clients started failing. Why? Some clients were using strict schema validation and rejected responses with unexpected fields. Others expected `items` to always be present and crashed when calling old endpoints.

That day I learned: **APIs are contracts**. Breaking them costs money, trust, and sleep.

## REST Principles with FastAPI

Here's how I design APIs for the Restaurant Service:

### Resource-Oriented URLs

```python
# restaurant/router.py
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional

router = APIRouter(prefix="/api/v1/restaurant", tags=["Restaurant"])

# Collection resource
@router.get("/menus")
async def list_menus(
    tenant_id: str = Depends(get_tenant_id),
    skip: int = Query(0, ge=0),
    limit: int = Query(100, ge=1, le=1000),
    category: Optional[str] = None
):
    """List all menus for tenant"""
    pass

# Individual resource
@router.get("/menus/{menu_id}")
async def get_menu(
    menu_id: int,
    tenant_id: str = Depends(get_tenant_id)
):
    """Get specific menu"""
    pass

# Create resource
@router.post("/menus", status_code=201)
async def create_menu(
    menu: MenuCreate,
    tenant_id: str = Depends(get_tenant_id)
):
    """Create a new menu"""
    pass

# Update resource (full replacement)
@router.put("/menus/{menu_id}")
async def update_menu(
    menu_id: int,
    menu: MenuUpdate,
    tenant_id: str = Depends(get_tenant_id)
):
    """Update entire menu"""
    pass

# Partial update
@router.patch("/menus/{menu_id}")
async def patch_menu(
    menu_id: int,
    updates: MenuPatch,
    tenant_id: str = Depends(get_tenant_id)
):
    """Partially update menu"""
    pass

# Delete resource
@router.delete("/menus/{menu_id}", status_code=204)
async def delete_menu(
    menu_id: int,
    tenant_id: str = Depends(get_tenant_id)
):
    """Delete menu"""
    pass

# Sub-resources
@router.get("/menus/{menu_id}/items")
async def get_menu_items(
    menu_id: int,
    tenant_id: str = Depends(get_tenant_id)
):
    """Get all items in a menu"""
    pass

# Actions on resources
@router.post("/menus/{menu_id}/publish")
async def publish_menu(
    menu_id: int,
    tenant_id: str = Depends(get_tenant_id)
):
    """Publish menu (make it active)"""
    pass
```

### HTTP Status Codes (Use Them Correctly!)

```python
from fastapi import status

# Success responses
@router.post("/menus", status_code=status.HTTP_201_CREATED)  # Created
@router.delete("/menus/{menu_id}", status_code=status.HTTP_204_NO_CONTENT)  # Deleted
@router.get("/menus")  # 200 OK (default)

# Error handling
@router.get("/menus/{menu_id}")
async def get_menu(menu_id: int, tenant_id: str = Depends(get_tenant_id)):
    menu = service.get_menu(menu_id, tenant_id)
    
    if not menu:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Menu {menu_id} not found"
        )
    
    return menu

@router.post("/menus")
async def create_menu(menu: MenuCreate, tenant_id: str = Depends(get_tenant_id)):
    try:
        return service.create_menu(menu, tenant_id)
    except DuplicateMenuError:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Menu with this name already exists"
        )
    except ValidationError as e:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=str(e)
        )

@router.post("/menus/{menu_id}/publish")
async def publish_menu(menu_id: int, user = Depends(get_current_user)):
    if not user.can_publish_menus():
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Insufficient permissions to publish menus"
        )
    
    return service.publish_menu(menu_id, user.tenant_id)
```

## Request/Response Models with Pydantic

Pydantic models are your contract. Make them explicit and strict:

```python
# restaurant/schemas.py
from pydantic import BaseModel, Field, validator
from typing import List, Optional
from datetime import datetime
from enum import Enum

class MenuCategory(str, Enum):
    APPETIZER = "appetizer"
    MAIN_COURSE = "main_course"
    DESSERT = "dessert"
    BEVERAGE = "beverage"

class MenuItemBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=1000)
    price: float = Field(..., gt=0, description="Price in USD")
    category: MenuCategory
    is_available: bool = True

class MenuItemCreate(MenuItemBase):
    """Request model for creating menu item"""
    
    @validator('price')
    def price_must_have_two_decimals(cls, v):
        if round(v, 2) != v:
            raise ValueError('Price must have at most 2 decimal places')
        return v
    
    @validator('name')
    def name_must_not_be_empty(cls, v):
        if not v.strip():
            raise ValueError('Name cannot be empty or whitespace')
        return v.strip()

class MenuItemResponse(MenuItemBase):
    """Response model for menu item"""
    id: int
    menu_id: int
    created_at: datetime
    updated_at: Optional[datetime]
    
    class Config:
        from_attributes = True  # For SQLAlchemy models

class MenuItemUpdate(BaseModel):
    """Request model for updating menu item (all fields optional)"""
    name: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=1000)
    price: Optional[float] = Field(None, gt=0)
    category: Optional[MenuCategory]
    is_available: Optional[bool]

class MenuBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=200)
    description: Optional[str] = None
    is_active: bool = False

class MenuCreate(MenuBase):
    """Request model for creating menu"""
    items: List[MenuItemCreate] = Field(default_factory=list)

class MenuResponse(MenuBase):
    """Response model for menu"""
    id: int
    tenant_id: str
    item_count: int  # Computed field
    created_at: datetime
    updated_at: Optional[datetime]
    
    class Config:
        from_attributes = True

class MenuDetailResponse(MenuResponse):
    """Detailed response including items"""
    items: List[MenuItemResponse]

class MenuListResponse(BaseModel):
    """Paginated list response"""
    items: List[MenuResponse]
    total: int
    skip: int
    limit: int
    has_more: bool
    
    @classmethod
    def from_query(cls, items: List, total: int, skip: int, limit: int):
        return cls(
            items=items,
            total=total,
            skip=skip,
            limit=limit,
            has_more=(skip + len(items)) < total
        )

class ErrorResponse(BaseModel):
    """Standard error response"""
    error: str
    detail: Optional[str] = None
    timestamp: datetime = Field(default_factory=datetime.utcnow)

# Use in endpoints
@router.post("/menus", response_model=MenuResponse)
async def create_menu(menu: MenuCreate, ...):
    pass

@router.get("/menus", response_model=MenuListResponse)
async def list_menus(...):
    pass

@router.get("/menus/{menu_id}", response_model=MenuDetailResponse)
async def get_menu(...):
    pass
```

## OpenAPI Automatic Generation

FastAPI generates OpenAPI docs automatically. Make them useful:

````python
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi

app = FastAPI(
    title="POS System - Restaurant Service",
    description="Manage restaurant menus, tables, and dining operations",
    version="1.0.0",
    contact={
        "name": "POS System Team",
        "email": "support@pos-system.com"
    },
    license_info={
        "name": "MIT"
    }
)

# Customize endpoint documentation
@router.post(
    "/menus",
    response_model=MenuResponse,
    status_code=201,
    summary="Create a new menu",
    description="""
    Create a new restaurant menu.
    
    ## Requirements
    - User must have MANAGER or ADMIN role
    - Menu name must be unique within tenant
    
    ## Example Request
    ```json
    {
        "name": "Dinner Menu",
        "description": "Evening dining options",
        "items": [
            {
                "name": "Grilled Salmon",
                "description": "Fresh Atlantic salmon",
                "price": 24.99,
                "category": "main_course"
            }
        ]
    }
    ```
    """,
    responses={
        201: {"description": "Menu created successfully"},
        409: {"description": "Menu name already exists", "model": ErrorResponse},
        422: {"description": "Validation error", "model": ErrorResponse}
    }
)
async def create_menu(menu: MenuCreate, ...):
    pass

# Custom OpenAPI schema
def custom_openapi():
    if app.openapi_schema:
        return app.openapi_schema
    
    openapi_schema = get_openapi(
        title=app.title,
        version=app.version,
        description=app.description,
        routes=app.routes,
    )
    
    # Add custom sections
    openapi_schema["info"]["x-logo"] = {
        "url": "https://pos-system.com/logo.png"
    }
    
    # Add security schemes
    openapi_schema["components"]["securitySchemes"] = {
        "BearerAuth": {
            "type": "http",
            "scheme": "bearer",
            "bearerFormat": "JWT"
        },
        "TenantHeader": {
            "type": "apiKey",
            "in": "header",
            "name": "x-tenant-id"
        }
    }
    
    app.openapi_schema = openapi_schema
    return app.openapi_schema

app.openapi = custom_openapi
````

## API Versioning Strategies

I learned about versioning the hard way. Here are three approaches:

### Strategy 1: URL Versioning (What I Use)

```python
# Explicit, visible versioning in URL
from fastapi import FastAPI

app = FastAPI()

# Version 1
v1_router = APIRouter(prefix="/api/v1")

@v1_router.get("/menus")
async def list_menus_v1():
    return {"version": "v1", "menus": [...]}

# Version 2 with breaking changes
v2_router = APIRouter(prefix="/api/v2")

@v2_router.get("/menus")
async def list_menus_v2():
    # New response format
    return {
        "version": "v2",
        "data": {"menus": [...]},
        "meta": {"total": 10}
    }

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

# Pros: Clear, easy to route, easy to deprecate
# Cons: URL changes when version changes
```

### Strategy 2: Header Versioning

```python
from fastapi import Header, HTTPException

@app.get("/api/menus")
async def list_menus(api_version: str = Header("1.0", alias="X-API-Version")):
    if api_version == "1.0":
        return list_menus_v1()
    elif api_version == "2.0":
        return list_menus_v2()
    else:
        raise HTTPException(400, f"Unsupported API version: {api_version}")

# Pros: URL stays same, clean URLs
# Cons: Version hidden, harder to test
```

### Strategy 3: Content Negotiation (Media Type)

```python
from fastapi import Request, Response

@app.get("/api/menus")
async def list_menus(request: Request):
    accept_header = request.headers.get("Accept", "")
    
    if "application/vnd.pos.v1+json" in accept_header:
        return Response(
            content=json.dumps(list_menus_v1()),
            media_type="application/vnd.pos.v1+json"
        )
    elif "application/vnd.pos.v2+json" in accept_header:
        return Response(
            content=json.dumps(list_menus_v2()),
            media_type="application/vnd.pos.v2+json"
        )
    else:
        # Default to latest version
        return list_menus_v2()

# Pros: RESTful, follows HTTP standards
# Cons: Complex, rarely used in practice
```

## Breaking vs Non-Breaking Changes

```python
# NON-BREAKING CHANGES (Safe to deploy)

# 1. Adding optional fields to response
class MenuResponse(BaseModel):
    id: int
    name: str
    items: List[MenuItem]
    # Safe to add:
    created_at: Optional[datetime] = None  # Optional, has default

# 2. Adding new endpoints
@router.get("/menus/{menu_id}/analytics")  # New endpoint
async def get_menu_analytics(...):
    pass

# 3. Adding optional query parameters
@router.get("/menus")
async def list_menus(
    skip: int = 0,
    limit: int = 100,
    sort_by: Optional[str] = None  # New, optional parameter
):
    pass

# 4. Deprecating (but not removing) fields
class MenuResponse(BaseModel):
    id: int
    name: str
    total_price: float  # Deprecated, kept for compatibility
    calculated_total: float = Field(alias="total_price")  # New field


# BREAKING CHANGES (Require new API version)

# 1. Removing fields from response
class MenuResponseV2(BaseModel):
    id: int
    name: str
    # BREAKING: Removed 'description' field

# 2. Changing field types
class MenuResponseV2(BaseModel):
    id: str  # BREAKING: Changed from int to str
    price: int  # BREAKING: Changed from float to int

# 3. Making optional fields required
class MenuCreateV2(BaseModel):
    name: str
    description: str  # BREAKING: Was optional, now required

# 4. Changing URL structure
# v1: GET /api/v1/menus/{menu_id}
# v2: GET /api/v2/restaurants/{restaurant_id}/menus/{menu_id}  # BREAKING

# 5. Changing response structure
# v1: {id: 1, name: "Menu"}
# v2: {data: {id: 1, name: "Menu"}}  # BREAKING: Wrapped in 'data'
```

## Managing API Evolution

Here's how I manage multiple versions in production:

```python
# restaurant/versions/v1.py
from fastapi import APIRouter, Depends
from restaurant.schemas.v1 import MenuResponse as MenuResponseV1

router_v1 = APIRouter(prefix="/api/v1/restaurant")

@router_v1.get("/menus/{menu_id}", response_model=MenuResponseV1)
async def get_menu_v1(menu_id: int, tenant_id: str = Depends(get_tenant_id)):
    menu = service.get_menu(menu_id, tenant_id)
    # Convert internal model to v1 response format
    return MenuResponseV1(
        id=menu.id,
        name=menu.name,
        description=menu.description,
        total_items=len(menu.items)
    )

# restaurant/versions/v2.py
from fastapi import APIRouter, Depends
from restaurant.schemas.v2 import MenuDetailResponse as MenuResponseV2

router_v2 = APIRouter(prefix="/api/v2/restaurant")

@router_v2.get("/menus/{menu_id}", response_model=MenuResponseV2)
async def get_menu_v2(menu_id: int, tenant_id: str = Depends(get_tenant_id)):
    menu = service.get_menu(menu_id, tenant_id)
    # Convert to v2 format (includes more details)
    return MenuResponseV2(
        id=menu.id,
        name=menu.name,
        description=menu.description,
        items=[MenuItemResponse(**item.__dict__) for item in menu.items],
        metadata={
            "created_at": menu.created_at,
            "updated_at": menu.updated_at,
            "is_active": menu.is_active
        }
    )

# main.py
app.include_router(router_v1, tags=["Restaurant v1"])
app.include_router(router_v2, tags=["Restaurant v2"])

# Deprecation warnings
@router_v1.get("/menus/{menu_id}")
async def get_menu_v1(...):
    # Add header to warn clients
    response.headers["X-API-Deprecation"] = "This endpoint is deprecated. Use /api/v2/restaurant/menus"
    response.headers["X-API-Sunset"] = "2024-12-31"  # When it will be removed
    return ...
```

## Backward Compatibility Techniques

```python
# Technique 1: Response adapters
class ResponseAdapter:
    @staticmethod
    def to_v1(menu) -> MenuResponseV1:
        return MenuResponseV1(
            id=menu.id,
            name=menu.name,
            total_items=len(menu.items)
        )
    
    @staticmethod
    def to_v2(menu) -> MenuResponseV2:
        return MenuResponseV2(
            id=menu.id,
            name=menu.name,
            items=menu.items,
            metadata={...}
        )

# Technique 2: Field aliases for renames
class MenuResponseV2(BaseModel):
    menu_id: int = Field(alias="id")  # Renamed from 'id' to 'menu_id'
    
    class Config:
        populate_by_name = True  # Accept both 'id' and 'menu_id'

# Technique 3: Optional wrapper for new required fields
class MenuCreateV2(BaseModel):
    name: str
    category: Optional[str] = "general"  # New required field with default
    
    @validator('category', always=True)
    def set_default_category(cls, v):
        return v or "general"
```

## Key Learnings

1. **APIs are contracts—treat breaking changes seriously**
   * Version your API from day one
   * Never break existing clients
2. **Make invalid states unrepresentable**
   * Use Pydantic validation
   * Fail fast with clear errors
3. **Documentation is part of the contract**
   * OpenAPI docs should be accurate
   * Include examples for every endpoint
4. **Prefer additive changes**
   * Add new fields as optional
   * Deprecate before removing
5. **Version explicitly in URL**
   * Clear, visible, easy to test
   * Clients know exactly what they're using

## Common Mistakes

1. **No versioning from the start**
   * Adding versioning later is painful
   * Start with /api/v1 on day one
2. **Changing response shapes without versioning**
   * Breaks clients silently
   * Always introduce as new version
3. **Weak request validation**
   * Accepting invalid data causes bugs downstream
   * Validate strictly at API boundary
4. **Inconsistent error responses**
   * Different endpoints return different error formats
   * Standardize error structure

## When to Use These Patterns

**URL Versioning:**

* Most REST APIs
* Clear deprecation path needed
* Multiple versions in production

**Header Versioning:**

* Internal APIs
* Need stable URLs
* Complex routing logic

**Media Type Versioning:**

* Following strict REST
* Content negotiation important
* Rare in practice

## Next Steps

Now that you understand API design, the next article explores authentication and authorization architecture—how to secure these APIs and manage user permissions.

**Next Article:** [06-authentication-authorization-architecture.md](https://blog.htunnthuthu.com/architecture-and-design/architecture-and-patterns/software-architecture-101/06-authentication-authorization-architecture)

***

*Remember: Your API is a product. Breaking it breaks your users' code. Version early, deprecate gracefully, and never surprise your clients.*
