Lab 09: REST APIs — FastAPI

Objective

Build a production-quality REST API using FastAPI: Pydantic models, path/query parameters, request body, dependency injection, error handling, and automatic OpenAPI docs.

Time

35 minutes

Prerequisites

  • Lab 07 (Type Hints), Lab 08 (SQLite)

Tools

  • Docker image: zchencow/innozverse-python:latest (FastAPI + uvicorn + pydantic)


Lab Instructions

Step 1: FastAPI Basics with Pydantic Models

docker run --rm zchencow/innozverse-python:latest python3 -c "
from fastapi import FastAPI, HTTPException, Query, Path
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import datetime

# Pydantic models
class ProductBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    price: float = Field(..., gt=0, description='Price in USD')
    stock: int = Field(default=0, ge=0)
    category: str = Field(default='General')

class ProductCreate(ProductBase):
    pass

class ProductUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1)
    price: Optional[float] = Field(None, gt=0)
    stock: Optional[int] = Field(None, ge=0)
    category: Optional[str] = None

class ProductResponse(ProductBase):
    id: int
    status: str
    created_at: datetime

    class Config:
        from_attributes = True

# In-memory store
class Store:
    def __init__(self):
        self.products: dict[int, dict] = {}
        self.next_id = 1

    def create(self, data: dict) -> dict:
        p = {**data, 'id': self.next_id, 'created_at': datetime.now()}
        p['status'] = 'out_of_stock' if p['stock'] == 0 else 'active'
        self.products[self.next_id] = p
        self.next_id += 1
        return p

    def get(self, pid: int) -> dict | None:
        return self.products.get(pid)

    def list(self, category: str = None, min_stock: int = None) -> list[dict]:
        items = list(self.products.values())
        if category: items = [p for p in items if p['category'] == category]
        if min_stock is not None: items = [p for p in items if p['stock'] >= min_stock]
        return items

    def update(self, pid: int, data: dict) -> dict | None:
        p = self.products.get(pid)
        if not p: return None
        p.update({k: v for k, v in data.items() if v is not None})
        p['status'] = 'out_of_stock' if p['stock'] == 0 else 'active'
        return p

    def delete(self, pid: int) -> bool:
        return self.products.pop(pid, None) is not None

store = Store()
app = FastAPI(title='innoZverse Product API', version='1.0.0')

@app.get('/products', response_model=list[dict], tags=['Products'])
def list_products(
    category: Optional[str] = Query(None, description='Filter by category'),
    min_stock: Optional[int] = Query(None, ge=0, description='Min stock'),
):
    return store.list(category=category, min_stock=min_stock)

@app.post('/products', status_code=201, tags=['Products'])
def create_product(product: ProductCreate):
    return store.create(product.model_dump())

@app.get('/products/{product_id}', tags=['Products'])
def get_product(product_id: int = Path(..., gt=0)):
    p = store.get(product_id)
    if not p: raise HTTPException(status_code=404, detail=f'Product {product_id} not found')
    return p

@app.patch('/products/{product_id}', tags=['Products'])
def update_product(product_id: int, update: ProductUpdate):
    p = store.update(product_id, update.model_dump(exclude_unset=True))
    if not p: raise HTTPException(status_code=404, detail='Product not found')
    return p

@app.delete('/products/{product_id}', status_code=204, tags=['Products'])
def delete_product(product_id: int):
    if not store.delete(product_id):
        raise HTTPException(status_code=404, detail='Product not found')

# Test with TestClient (no server needed)
client = TestClient(app)

# Create
r = client.post('/products', json={'name': 'Surface Pro 12\"', 'price': 864.0, 'stock': 15, 'category': 'Laptop'})
print(f'POST /products: {r.status_code} → id={r.json()[\"id\"]}')

client.post('/products', json={'name': 'Surface Pen', 'price': 49.99, 'stock': 80, 'category': 'Accessory'})
client.post('/products', json={'name': 'Office 365', 'price': 99.99, 'stock': 999, 'category': 'Software'})

# List
r = client.get('/products')
print(f'GET /products: {r.status_code} → {len(r.json())} items')

# Get by ID
r = client.get('/products/1')
p = r.json()
print(f'GET /products/1: {r.status_code} → {p[\"name\"]} \${p[\"price\"]}')

# 404
r = client.get('/products/99')
print(f'GET /products/99: {r.status_code} → {r.json()[\"detail\"]}')

# Update
r = client.patch('/products/1', json={'price': 799.99})
print(f'PATCH /products/1: {r.status_code} → price=\${r.json()[\"price\"]}')

# Filter
r = client.get('/products?category=Laptop')
print(f'GET /products?category=Laptop: {len(r.json())} items')

# Validation error
r = client.post('/products', json={'name': '', 'price': -1})
print(f'POST invalid: {r.status_code}')
"

💡 Pydantic Field() provides validation at the schema level: gt=0 (greater than), ge=0 (greater or equal), min_length, max_length. FastAPI automatically returns 422 Unprocessable Entity with detailed error messages when validation fails — no manual validation code needed.

📸 Verified Output:


Step 2: Dependency Injection & Middleware

📸 Verified Output:


Steps 3–8: Background Tasks, Exception Handlers, Response Models, Routers, Streaming, Capstone

📸 Verified Output:


Summary

Feature
FastAPI
Notes

Route

@app.get('/path')

Supports GET, POST, PUT, PATCH, DELETE

Body model

class M(BaseModel):

Auto-validated, auto-documented

Path param

def f(id: int = Path(..., gt=0))

Type-checked

Query param

def f(q: str = Query(None))

Optional/required

Dependency

Depends(fn)

Reusable auth, DB, pagination

Background

BackgroundTasks.add_task(fn, ...)

Async post-response work

Error

HTTPException(status_code, detail)

Automatic JSON response

Testing

TestClient(app)

No server needed

Further Reading

Last updated