Skip to content

API Design — Thiết kế API chuyên nghiệp

Năm 2022, một sàn thương mại điện tử tại Việt Nam đổi cấu trúc response từ { data: [...] } sang { items: [...] } trên API danh sách sản phẩm — không versioning, không deprecation notice. Kết quả: 47 ứng dụng đối tác ngừng hoạt động trong 3 giờ, hàng trăm đơn hàng bị mất, đội kỹ thuật rollback giữa đêm. Một thay đổi nhỏ, hậu quả lớn — bởi API không chỉ là code, mà là hợp đồng giữa hệ thống của bạn và mọi client phụ thuộc vào nó.

API design là nghệ thuật cân bằng giữa tính nhất quán (consistency), khả năng mở rộng (scalability), và trải nghiệm developer. Một API tốt giống bộ hợp đồng rõ ràng: client biết chính xác cần gửi gì, nhận lại gì, xử lý lỗi ra sao — không cần đoán.

Bài viết đi sâu vào các pattern đã kiểm chứng trong production: nguyên tắc RESTful, chiến lược versioning, pagination hiệu quả, error handling nhất quán, và rate limiting. Mỗi pattern kèm lý do "tại sao" trước "cách làm", cùng code FastAPI production-grade.

Bức tranh tư duy

Hãy hình dung API như thực đơn của một nhà hàng. Thực đơn không phải là bếp — nó là giao diện giữa khách hàng (client) và nhà bếp (server). Một thực đơn tốt phải:

  • Rõ ràng: Mỗi món có tên, mô tả, giá — client biết chính xác mình nhận gì
  • Nhất quán: Tất cả món khai vị nằm cùng mục, không trộn lẫn với tráng miệng
  • Ổn định: Không đổi tên món giữa chừng khiến khách quen không nhận ra
  • Có phiên bản: Khi cập nhật thực đơn, giữ lại món cũ để khách chuyển dần
┌─────────────────────────────────────────────────────────┐
│                 API = THỰC ĐƠN NHÀ HÀNG                │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Client (Khách hàng)                                    │
│       │                                                 │
│       ▼                                                 │
│  ┌───────────┐   ┌────────────┐   ┌─────────────┐      │
│  │ Resource  │   │  Method    │   │  Response   │      │
│  │ (Tên món) │   │ (Cách gọi) │   │ (Món ăn)    │      │
│  │           │   │            │   │             │      │
│  │ /products │   │ GET = Xem  │   │ 200 = Thành │      │
│  │ /orders   │   │ POST = Đặt │   │ 404 = Hết   │      │
│  │ /users    │   │ PUT = Đổi  │   │ 429 = Quá   │      │
│  │ /reviews  │   │ DELETE=Huỷ │   │ 500 = Sự cố │      │
│  └───────────┘   └────────────┘   └─────────────┘      │
│                                                         │
│  Versioning = Thực đơn mới giữ lại món cũ               │
│  Pagination = Chia thực đơn thành nhiều trang            │
│  Rate Limit = Giới hạn số lần gọi mỗi phút              │
│                                                         │
└─────────────────────────────────────────────────────────┘

Với mô hình này, mỗi quyết định thiết kế API đều quy về: "Nếu đây là thực đơn, khách hàng có hiểu ngay không?"

Cốt lõi kỹ thuật

Nguyên tắc REST và đặt tên resource

REST (Representational State Transfer) là phong cách kiến trúc xây dựng trên HTTP. Nguyên tắc cốt lõi: mọi thứ là tài nguyên (resource), định danh bằng URL, thao tác qua HTTP method.

Tại sao resource-oriented quan trọng? Vì nó tạo convention mà mọi developer hiểu ngay. Thấy GET /products/123 — ai cũng biết đó là "lấy sản phẩm có ID 123", không cần đọc docs.

python
# ✅ Danh từ số nhiều, lowercase, kebab-case
GET    /products              # Danh sách sản phẩm
GET    /products/123          # Chi tiết sản phẩm 123
POST   /products              # Tạo sản phẩm mới
PUT    /products/123          # Cập nhật toàn bộ sản phẩm 123
PATCH  /products/123          # Cập nhật một phần
DELETE /products/123          # Xoá sản phẩm 123

# ✅ Nesting thể hiện quan hệ sở hữu (tối đa 2 cấp)
GET    /sellers/456/products  # Sản phẩm của seller 456
GET    /products/123/reviews  # Đánh giá sản phẩm 123

# ❌ SAI: Động từ, camelCase, singular
GET    /getProduct/123        # Động từ
POST   /createOrder           # Động từ
GET    /productCategories     # camelCase

# Quá sâu → tách resource, dùng filter
# ❌ /sellers/456/products/123/reviews/789/replies
# ✅ /replies?review_id=789

HTTP method và status code

Mỗi HTTP method có ngữ nghĩa riêng — không phải convention tuỳ chọn, mà là đặc tả mà cache, proxy, CDN đều hiểu.

MethodMục đíchIdempotentAn toàn
GETĐọc resource
POSTTạo resource mới
PUTThay thế toàn bộ
PATCHCập nhật một phần
DELETEXoá resource

Tại sao idempotent quan trọng? Khi network timeout, client retry request. PUT /products/123 gọi 5 lần vẫn cho kết quả giống nhau — an toàn retry. Nhưng POST /orders gọi 5 lần có thể tạo 5 đơn hàng.

Status code — nói đúng chuyện xảy ra:

python
# 2xx — Thành công
200  # OK: GET, PUT, PATCH thành công
201  # Created: POST tạo resource thành công
204  # No Content: DELETE thành công

# 4xx — Lỗi từ client
400  # Bad Request: body không hợp lệ
401  # Unauthorized: chưa xác thực
403  # Forbidden: không có quyền
404  # Not Found: resource không tồn tại
409  # Conflict: trùng lặp (email đã tồn tại)
422  # Unprocessable Entity: format đúng, logic sai
429  # Too Many Requests: vượt rate limit

# 5xx — Lỗi từ server
500  # Internal Server Error
503  # Service Unavailable: đang bảo trì

Chiến lược versioning

Tại sao cần versioning? API là hợp đồng. Đổi cấu trúc response = phá vỡ mọi client hiện tại. Versioning cho phép phát triển API mà không ảnh hưởng client cũ.

python
# 1. URL PATH (phổ biến nhất) — rõ ràng, dễ cache
GET /api/v1/products
GET /api/v2/products

# 2. HEADER — URL sạch, linh hoạt (khó test bằng browser)
GET /api/products
Accept: application/vnd.penalgo.v2+json

# 3. QUERY PARAM — đơn giản (dễ quên, ảnh hưởng cache)
GET /api/products?version=2

Khuyến nghị: URL path cho public API (rõ ràng), header cho internal API (linh hoạt).

python
from fastapi import FastAPI, APIRouter

app = FastAPI(title="Marketplace API")
router_v1 = APIRouter(prefix="/api/v1", tags=["v1"])
router_v2 = APIRouter(prefix="/api/v2", tags=["v2"])

@router_v1.get("/products/{product_id}")
async def get_product_v1(product_id: int):
    """V1: Flat structure."""
    return {"id": product_id, "price": 25_000_000, "seller_name": "TechShop VN"}

@router_v2.get("/products/{product_id}")
async def get_product_v2(product_id: int):
    """V2: Nested structure với seller object."""
    return {
        "id": product_id,
        "price": {"amount": 25_000_000, "currency": "VND"},
        "seller": {"id": 456, "name": "TechShop VN", "rating": 4.8},
    }

app.include_router(router_v1)
app.include_router(router_v2)

Pagination — offset vs cursor

Khi dataset lớn, trả toàn bộ dữ liệu = thảm hoạ. Hai pattern phân trang (pagination) chính:

python
# OFFSET: Dễ hiểu, nhảy trang được
GET /products?offset=20&limit=10
# → {"items": [...], "total": 1543, "offset": 20, "limit": 10}

# CURSOR: Hiệu năng ổn định, nhất quán
GET /products?limit=10
# → {"items": [...], "next_cursor": "eyJpZCI6IDMwfQ==", "has_more": true}
GET /products?cursor=eyJpZCI6IDMwfQ==&limit=10
Tiêu chíOffsetCursor
Nhảy đến trang N✅ Dễ❌ Không hỗ trợ
Hiệu năng dataset lớn❌ Chậm dần✅ Ổn định
Dữ liệu thay đổi liên tục❌ Lặp/mất item✅ Nhất quán
Phù hợpAdmin panel, báo cáoFeed, mobile app

Tại sao offset chậm? SQL OFFSET 100000 LIMIT 10 buộc database đọc và bỏ 100.000 dòng trước khi trả 10 dòng. Cursor dùng WHERE id > last_id LIMIT 10 — nhảy thẳng bằng index.

Error handling nhất quán

Tại sao chuẩn hoá? Client cần parse error tự động. Error nhất quán = một hàm xử lý duy nhất cho tất cả endpoint.

python
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel

class ErrorCode(str, Enum):
    VALIDATION_ERROR = "VALIDATION_ERROR"
    RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
    DUPLICATE_RESOURCE = "DUPLICATE_RESOURCE"
    UNAUTHORIZED = "UNAUTHORIZED"
    FORBIDDEN = "FORBIDDEN"
    RATE_LIMITED = "RATE_LIMITED"
    INTERNAL_ERROR = "INTERNAL_ERROR"

class ErrorDetail(BaseModel):
    field: str
    message: str
    code: str

class ErrorResponse(BaseModel):
    error: ErrorCode
    message: str
    details: list[ErrorDetail] | None = None
    request_id: str
    timestamp: str

Ví dụ error response:

json
{
    "error": "VALIDATION_ERROR",
    "message": "Dữ liệu đầu vào không hợp lệ",
    "details": [
        {"field": "price", "message": "Giá phải lớn hơn 0", "code": "min_value"},
        {"field": "name", "message": "Tên không được trống", "code": "required"}
    ],
    "request_id": "req_abc123xyz",
    "timestamp": "2024-01-15T08:30:00Z"
}

Thực chiến

Tình huống: Thiết kế API cho nền tảng marketplace với hàng nghìn seller

Yêu cầu: API quản lý sản phẩm — seller đăng bán, buyer tìm kiếm, admin quản lý. Hỗ trợ cursor pagination, versioning, error handling nhất quán, rate limiting.

python
"""marketplace_api.py — Production API cho marketplace (FastAPI + Pydantic v2)"""
import base64, hashlib, json, time, uuid
from datetime import datetime, timezone
from enum import Enum
from typing import Annotated, Any, Generic, TypeVar

from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field, field_validator

# ── Domain & Schemas ───────────────────────────────────────

class ProductStatus(str, Enum):
    DRAFT = "draft"
    ACTIVE = "active"
    SOLD_OUT = "sold_out"
    SUSPENDED = "suspended"

class SortOrder(str, Enum):
    PRICE_ASC = "price_asc"
    PRICE_DESC = "price_desc"
    NEWEST = "newest"

class ErrorCode(str, Enum):
    VALIDATION_ERROR = "VALIDATION_ERROR"
    RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
    RATE_LIMITED = "RATE_LIMITED"
    INTERNAL_ERROR = "INTERNAL_ERROR"

class ErrorResponse(BaseModel):
    error: ErrorCode
    message: str
    details: list[dict] | None = None
    request_id: str
    timestamp: str

class PriceSchema(BaseModel):
    amount: int = Field(..., gt=0)
    currency: str = Field(default="VND", pattern=r"^[A-Z]{3}$")

class SellerSummary(BaseModel):
    id: int
    name: str
    rating: float = Field(..., ge=0.0, le=5.0)
    verified: bool

class ProductResponse(BaseModel):
    id: int
    name: str
    slug: str
    price: PriceSchema
    status: ProductStatus
    seller: SellerSummary
    category_id: int
    image_urls: list[str]
    created_at: datetime
    updated_at: datetime

class ProductCreateRequest(BaseModel):
    name: str = Field(..., min_length=5, max_length=200)
    description: str = Field(..., min_length=20, max_length=5000)
    price_amount: int = Field(..., gt=0, le=999_999_999)
    category_id: int = Field(..., gt=0)
    image_urls: list[str] = Field(..., min_length=1, max_length=10)

    @field_validator("name")
    @classmethod
    def no_leading_trailing_spaces(cls, v: str) -> str:
        if v.strip() != v:
            raise ValueError("Tên không được có khoảng trắng đầu/cuối")
        return v

# ── Cursor Pagination ──────────────────────────────────────

DataT = TypeVar("DataT")

class CursorPage(BaseModel, Generic[DataT]):
    items: list[DataT]
    next_cursor: str | None = None
    has_more: bool

def encode_cursor(product_id: int, sort_value: Any) -> str:
    payload = json.dumps({"id": product_id, "sv": str(sort_value)})
    return base64.urlsafe_b64encode(payload.encode()).decode()

def decode_cursor(cursor: str) -> dict:
    try:
        data = json.loads(base64.urlsafe_b64decode(cursor.encode()).decode())
        if "id" not in data:
            raise ValueError
        return data
    except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
        raise HTTPException(400, detail="Cursor không hợp lệ") from exc

# ── Rate Limiting — Token Bucket ───────────────────────────

class TokenBucket:
    """Xô token: mỗi request tiêu 1 token, nạp đều theo thời gian."""
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity = capacity
        self.refill_rate = refill_rate
        self._buckets: dict[str, dict] = {}

    def consume(self, key: str) -> tuple[bool, dict]:
        now = time.monotonic()
        bucket = self._buckets.get(key)
        if not bucket:
            self._buckets[key] = {"tokens": self.capacity, "ts": now}
            bucket = self._buckets[key]
        else:
            elapsed = now - bucket["ts"]
            bucket["tokens"] = min(self.capacity, bucket["tokens"] + elapsed * self.refill_rate)
            bucket["ts"] = now
        allowed = bucket["tokens"] >= 1
        if allowed:
            bucket["tokens"] -= 1
        return allowed, {"limit": self.capacity, "remaining": max(0, int(bucket["tokens"]))}

rate_limiter = TokenBucket(capacity=100, refill_rate=100 / 60)  # 100 req/phút

# ── Dependencies & App ─────────────────────────────────────

async def get_request_id(request: Request) -> str:
    return request.headers.get("X-Request-ID", f"req_{uuid.uuid4().hex[:16]}")

async def check_rate_limit(request: Request) -> None:
    client_ip = request.client.host if request.client else "unknown"
    allowed, meta = rate_limiter.consume(client_ip)
    if not allowed:
        raise HTTPException(429, detail="Vượt quá giới hạn request",
            headers={"X-RateLimit-Limit": str(meta["limit"]), "X-RateLimit-Remaining": "0"})

app = FastAPI(title="Marketplace API", version="2.0.0")
v2 = APIRouter(prefix="/api/v2", tags=["v2"], dependencies=[Depends(check_rate_limit)])

# ── Endpoints ──────────────────────────────────────────────

@v2.get("/products", response_model=CursorPage[ProductResponse])
async def list_products(
    request_id: Annotated[str, Depends(get_request_id)],
    response: Response,
    cursor: str | None = Query(None),
    limit: int = Query(20, ge=1, le=100),
    sort: SortOrder = Query(SortOrder.NEWEST),
    category_id: int | None = Query(None, ge=1),
    search: str | None = Query(None, max_length=200),
):
    """Danh sách sản phẩm — cursor pagination, filter, sort."""
    response.headers["X-Request-ID"] = request_id
    if cursor:
        decode_cursor(cursor)  # Validate + dùng trong WHERE clause

    # Production: thay bằng query DB thực tế
    now = datetime.now(timezone.utc)
    sample = [
        ProductResponse(
            id=i, name=f"Sản phẩm {i}", slug=f"san-pham-{i}",
            price=PriceSchema(amount=500_000 * i), status=ProductStatus.ACTIVE,
            seller=SellerSummary(id=1, name="TechShop", rating=4.8, verified=True),
            category_id=category_id or 1,
            image_urls=[f"https://cdn.example.vn/{i}.webp"],
            created_at=now, updated_at=now,
        ) for i in range(1, limit + 1)
    ]
    has_more = len(sample) == limit
    next_cursor = encode_cursor(sample[-1].id, now.isoformat()) if has_more else None
    return CursorPage(items=sample, next_cursor=next_cursor, has_more=has_more)

@v2.get("/products/{product_id}", response_model=ProductResponse,
        responses={404: {"model": ErrorResponse}})
async def get_product(product_id: int,
    request_id: Annotated[str, Depends(get_request_id)], response: Response):
    """Chi tiết sản phẩm — kèm ETag caching."""
    response.headers["X-Request-ID"] = request_id
    etag = hashlib.md5(f"product:{product_id}:v1".encode()).hexdigest()
    response.headers["ETag"] = f'"{etag}"'
    response.headers["Cache-Control"] = "private, max-age=60"
    now = datetime.now(timezone.utc)
    return ProductResponse(
        id=product_id, name="Laptop Gaming Pro", slug="laptop-gaming-pro",
        price=PriceSchema(amount=25_000_000), status=ProductStatus.ACTIVE,
        seller=SellerSummary(id=456, name="TechShop", rating=4.8, verified=True),
        category_id=10, image_urls=["https://cdn.example.vn/laptop.webp"],
        created_at=now, updated_at=now,
    )

@v2.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(body: ProductCreateRequest,
    request_id: Annotated[str, Depends(get_request_id)], response: Response):
    """Tạo sản phẩm mới — trả 201 + Location header."""
    response.headers["X-Request-ID"] = request_id
    response.headers["Location"] = "/api/v2/products/999"
    now = datetime.now(timezone.utc)
    return ProductResponse(
        id=999, name=body.name, slug=body.name.lower().replace(" ", "-"),
        price=PriceSchema(amount=body.price_amount), status=ProductStatus.DRAFT,
        seller=SellerSummary(id=1, name="Seller", rating=4.5, verified=True),
        category_id=body.category_id, image_urls=body.image_urls,
        created_at=now, updated_at=now,
    )

@v2.delete("/products/{product_id}", status_code=204)
async def delete_product(product_id: int,
    request_id: Annotated[str, Depends(get_request_id)], response: Response):
    """Soft delete — đánh dấu suspended thay vì xoá thật."""
    response.headers["X-Request-ID"] = request_id
    return Response(status_code=204)

app.include_router(v2)

Điểm đáng chú ý trong production code:

  1. Cursor pagination — ổn định với dataset lớn, dữ liệu thay đổi liên tục
  2. Token Bucket rate limiting — cho phép burst hợp lý, mượt hơn fixed window
  3. Request ID xuyên suốt — mọi response có X-Request-ID để trace
  4. ETag caching — giảm tải khi client request cùng resource
  5. Soft delete — không xoá thật, giữ audit trail
  6. Error response chuẩn hoá — mọi lỗi cùng format ErrorResponse

Sai lầm điển hình

Sai lầm 1: Dùng động từ trong URL

python
# ❌ SAI
@app.post("/api/createProduct")
async def create_product(body: dict): ...

@app.get("/api/getProductById/{id}")
async def get_product(id: int): ...
python
# ✅ ĐÚNG — HTTP method đã thể hiện hành động
@app.post("/api/v2/products", status_code=201)
async def create_product(body: ProductCreateRequest): ...

@app.get("/api/v2/products/{product_id}")
async def get_product(product_id: int): ...

Tại sao? HTTP method đã là động từ (GET=đọc, POST=tạo). Thêm động từ vào URL là thừa thãi, phá convention REST, client không thể dự đoán pattern.

Sai lầm 2: Error response không nhất quán

python
# ❌ SAI — mỗi endpoint trả format khác nhau
@app.get("/products/{id}")
async def get_product(id: int):
    raise HTTPException(404, detail="Not found")  # string

@app.post("/orders")
async def create_order(body: dict):
    raise HTTPException(400, detail={"msg": "Invalid"})  # dict khác cấu trúc
python
# ✅ ĐÚNG — custom exception + global handler
class APIError(Exception):
    def __init__(self, status_code: int, error: ErrorCode, message: str,
                 details: list[ErrorDetail] | None = None):
        self.status_code = status_code
        self.error = error
        self.message = message
        self.details = details

@app.exception_handler(APIError)
async def api_error_handler(request: Request, exc: APIError) -> JSONResponse:
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": exc.error.value,
            "message": exc.message,
            "details": [d.model_dump() for d in exc.details] if exc.details else None,
            "request_id": request.headers.get("X-Request-ID", "unknown"),
            "timestamp": datetime.now(timezone.utc).isoformat(),
        },
    )

Tại sao? Client viết một hàm handleApiError() cho tất cả lỗi. Mỗi endpoint trả format khác = client phải viết logic riêng cho từng endpoint.

Sai lầm 3: Không phân trang cho list endpoint

python
# ❌ SAI — sập server khi có 1 triệu records
@app.get("/products")
async def list_products():
    return await db.fetch_all("SELECT * FROM products")  # Response 500MB
python
# ✅ ĐÚNG — pagination bắt buộc, limit tối đa
@app.get("/products")
async def list_products(
    cursor: str | None = Query(None),
    limit: int = Query(20, ge=1, le=100),
):
    products = await repo.list_with_cursor(cursor=cursor, limit=limit + 1)
    has_more = len(products) > limit
    items = products[:limit]
    next_cursor = encode_cursor(items[-1].id, items[-1].created_at) if has_more else None
    return CursorPage(items=items, next_cursor=next_cursor, has_more=has_more)

Sai lầm 4: Breaking change không versioning

python
# ❌ SAI — đổi trực tiếp, mọi client cũ hỏng
# Trước: {"price": 500000}  → Sau: {"price": {"amount": 500000, "currency": "VND"}}
python
# ✅ ĐÚNG — version mới + deprecation notice
@router_v1.get("/products/{product_id}")
async def get_product_v1(product_id: int, response: Response):
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = "Sat, 01 Jun 2025 00:00:00 GMT"
    return {"id": product_id, "price": 500_000}

@router_v2.get("/products/{product_id}")
async def get_product_v2(product_id: int):
    return {"id": product_id, "price": {"amount": 500_000, "currency": "VND"}}

Quy tắc vàng: Thêm field → OK. Đổi/xoá field → version mới bắt buộc.

Sai lầm 5: Dùng 200 cho mọi response

python
# ❌ SAI — monitoring không phát hiện lỗi
@app.post("/products")
async def create_product(body: dict):
    return {"status": "error", "message": "Validation failed"}  # HTTP 200!

# ✅ ĐÚNG — status code phản ánh kết quả
@app.post("/products", status_code=201)
async def create_product(body: ProductCreateRequest):
    return product  # 201 Created

Tại sao? Proxy, CDN, monitoring dựa vào status code để cache, alert, retry. Trả 200 cho lỗi = monitoring mù.

Under the Hood

HTTP protocol và content negotiation

Khi client gửi request, header Accept cho server biết format mong muốn. Server phản hồi với Content-Type cho biết format thực tế:

GET /api/v2/products/123 HTTP/1.1
Host: api.marketplace.vn
Accept: application/json
If-None-Match: "abc123"
Authorization: Bearer eyJ...

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "abc123"
Cache-Control: private, max-age=60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-Request-ID: req_7f3a2b1c

Caching headers — giảm tải server

ETag (Entity Tag): Server gắn "dấu vân tay" cho resource. Client gửi lại If-None-Match. Resource chưa đổi → 304 Not Modified (không body) → tiết kiệm bandwidth.

Cache-Control directives:

Cache-Control: public, max-age=3600     # CDN + browser cache 1 giờ
Cache-Control: private, max-age=60      # Chỉ browser (dữ liệu cá nhân)
Cache-Control: no-cache                 # Validate với server trước khi dùng
Cache-Control: no-store                 # Không cache — dữ liệu nhạy cảm

Thuật toán rate limiting

Thuật toánCách hoạt độngƯu điểmNhược điểm
Fixed WindowĐếm request theo khung thời gian cố địnhĐơn giản, ít bộ nhớBurst ở ranh giới window
Sliding WindowTrung bình có trọng số giữa 2 windowMượt hơn fixedPhức tạp hơn
Token BucketXô token nạp đều, mỗi request tiêu 1Cho phép burst hợp lýState per-client
Leaky BucketHàng đợi xử lý tốc độ cố địnhOutput rate ổn địnhDelay thay vì reject

Bảng trade-off tổng hợp

Quyết địnhLựa chọn ALựa chọn BKhi nào AKhi nào B
PaginationOffsetCursorAdmin panel, nhảy trangFeed, dataset lớn
VersioningURL pathHeaderPublic APIInternal API
ID formatAuto-incrementUUIDInternal, sort by IDDistributed, bảo mật
DeleteHardSoftGDPR, dữ liệu rácAudit trail, khôi phục
Rate limitPer-IPPer-API-keyPublic không authAPI có authentication

Checklist ghi nhớ

✅ Checklist triển khai

Resource Design

  • [ ] URL chứa danh từ số nhiều, lowercase, kebab-case
  • [ ] Nesting tối đa 2 cấp
  • [ ] HTTP method phản ánh đúng hành động
  • [ ] Status code phản ánh đúng kết quả

Versioning & Compatibility

  • [ ] API có version rõ ràng (URL path hoặc header)
  • [ ] Thêm field = OK, đổi/xoá field = version mới
  • [ ] Version cũ có Deprecation header và Sunset date
  • [ ] Changelog cho mỗi version

Pagination & Performance

  • [ ] Mọi list endpoint có pagination bắt buộc
  • [ ] limit có giá trị tối đa (thường 100)
  • [ ] Cursor-based cho dataset lớn hoặc dữ liệu thay đổi liên tục
  • [ ] Response kèm has_more hoặc next_cursor

Error Handling & Security

  • [ ] Một cấu trúc error response duy nhất cho toàn bộ API
  • [ ] Error có request_id để trace trong log
  • [ ] Rate limiting với header X-RateLimit-*
  • [ ] Không expose stack trace trong production

Bài tập luyện tập

Bài 1: Thiết kế resource URL

🧠 Quiz

Cho hệ thống quản lý khoá học trực tuyến: Course, Lesson, Enrollment, Review. Thiết kế URL cho:

  1. Lấy danh sách khoá học
  2. Tạo khoá học mới
  3. Lấy bài học trong khoá học 42
  4. Học viên đăng ký khoá học 42
  5. Lấy đánh giá khoá học 42
Xem lời giải
python
GET  /api/v1/courses?limit=20&cursor=...       # 1. Danh sách khoá học
POST /api/v1/courses                            # 2. Tạo khoá học
GET  /api/v1/courses/42/lessons                 # 3. Bài học trong khoá 42
POST /api/v1/courses/42/enrollments             # 4. Đăng ký (user từ JWT)
GET  /api/v1/courses/42/reviews?sort=newest     # 5. Đánh giá khoá 42

# Enrollment là resource (không phải action "enroll")
# Mọi list endpoint đều có pagination

Bài 2: Xây dựng error handler thống nhất

🧠 Quiz

Viết exception handler cho FastAPI đảm bảo:

  1. Lỗi validation (422) trả danh sách field lỗi cụ thể
  2. Lỗi 404 mô tả resource nào không tìm thấy
  3. Lỗi 500 không expose thông tin nội bộ, chỉ trả request_id
  4. Tất cả error cùng cấu trúc JSON
Xem lời giải
python
import logging
import uuid
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

logger = logging.getLogger(__name__)
app = FastAPI()

def _req_id(request: Request) -> str:
    return request.headers.get("X-Request-ID", f"req_{uuid.uuid4().hex[:16]}")

def _error_body(error: str, message: str, request: Request, details=None) -> dict:
    return {
        "error": error, "message": message, "details": details,
        "request_id": _req_id(request),
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    details = [
        {"field": "".join(str(l) for l in e["loc"]),
         "message": e["msg"], "code": e["type"]}
        for e in exc.errors()
    ]
    return JSONResponse(422, _error_body(
        "VALIDATION_ERROR", "Dữ liệu không hợp lệ", request, details))

@app.exception_handler(404)
async def not_found_handler(request: Request, exc: Exception):
    return JSONResponse(404, _error_body(
        "RESOURCE_NOT_FOUND", f"Không tìm thấy: {request.url.path}", request))

@app.exception_handler(500)
async def internal_handler(request: Request, exc: Exception):
    rid = _req_id(request)
    logger.error("Internal error | %s | %s", rid, exc, exc_info=True)
    return JSONResponse(500, _error_body(
        "INTERNAL_ERROR", "Lỗi hệ thống. Liên hệ hỗ trợ với mã request.", request))

Mấu chốt: Lỗi 422 trả chi tiết field → form inline error. Lỗi 500 chỉ trả request_id → bảo mật, tra log bằng ID.

Bài 3: Triển khai cursor pagination

🧠 Quiz

Viết cursor pagination cho GET /api/v2/orders:

  1. Sort theo created_at DESC
  2. Cursor mã hoá (created_at, id) để xử lý trùng timestamp
  3. Filter theo status
  4. Trả total chỉ khi include_total=true
Xem lời giải
python
import base64
from pydantic import BaseModel
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession

class OrderCursor(BaseModel):
    created_at: str
    id: int
    def encode(self) -> str:
        return base64.urlsafe_b64encode(self.model_dump_json().encode()).decode()
    @classmethod
    def decode(cls, raw: str) -> "OrderCursor":
        return cls.model_validate_json(base64.urlsafe_b64decode(raw.encode()).decode())

@router.get("/api/v2/orders")
async def list_orders(
    db: AsyncSession, cursor: str | None = Query(None),
    limit: int = Query(20, ge=1, le=100),
    order_status: str | None = Query(None, alias="status"),
    include_total: bool = Query(False),
):
    query = select(Order).order_by(desc(Order.created_at), desc(Order.id))
    if order_status:
        query = query.where(Order.status == order_status)
    if cursor:  # Composite cursor: (created_at, id) — xử lý trùng timestamp
        c = OrderCursor.decode(cursor)
        query = query.where(and_(
            Order.created_at <= c.created_at,
            ~and_(Order.created_at == c.created_at, Order.id >= c.id),
        ))

    results = (await db.execute(query.limit(limit + 1))).scalars().all()
    has_more = len(results) > limit
    orders = results[:limit]
    next_cursor = (OrderCursor(created_at=orders[-1].created_at.isoformat(),
                               id=orders[-1].id).encode() if has_more and orders else None)

    total = None  # COUNT(*) đắt — chỉ tính khi cần
    if include_total:
        cq = select(func.count()).select_from(Order)
        if order_status:
            cq = cq.where(Order.status == order_status)
        total = (await db.execute(cq)).scalar()
    return {"items": orders, "next_cursor": next_cursor, "has_more": has_more, "total": total}

Mấu chốt: Composite cursor (created_at, id) không bỏ sót record trùng timestamp. limit + 1 trick xác định has_more không cần COUNT.

Liên kết học tiếp