Giao diện
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=789HTTP 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.
| Method | Mục đích | Idempotent | An toàn |
|---|---|---|---|
GET | Đọc resource | ✅ | ✅ |
POST | Tạo resource mới | ❌ | ❌ |
PUT | Thay thế toàn bộ | ✅ | ❌ |
PATCH | Cập nhật một phần | ❌ | ❌ |
DELETE | Xoá 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=2Khuyế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í | Offset | Cursor |
|---|---|---|
| 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ợp | Admin panel, báo cáo | Feed, 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: strVí 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:
- Cursor pagination — ổn định với dataset lớn, dữ liệu thay đổi liên tục
- Token Bucket rate limiting — cho phép burst hợp lý, mượt hơn fixed window
- Request ID xuyên suốt — mọi response có
X-Request-IDđể trace - ETag caching — giảm tải khi client request cùng resource
- Soft delete — không xoá thật, giữ audit trail
- 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úcpython
# ✅ ĐÚ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 500MBpython
# ✅ ĐÚ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 CreatedTạ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_7f3a2b1cCaching 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ảmThuật toán rate limiting
| Thuật toán | Cách hoạt động | Ưu điểm | Nhượ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 Window | Trung bình có trọng số giữa 2 window | Mượt hơn fixed | Phức tạp hơn |
| Token Bucket | Xô token nạp đều, mỗi request tiêu 1 | Cho phép burst hợp lý | State per-client |
| Leaky Bucket | Hàng đợi xử lý tốc độ cố định | Output rate ổn định | Delay thay vì reject |
Bảng trade-off tổng hợp
| Quyết định | Lựa chọn A | Lựa chọn B | Khi nào A | Khi nào B |
|---|---|---|---|---|
| Pagination | Offset | Cursor | Admin panel, nhảy trang | Feed, dataset lớn |
| Versioning | URL path | Header | Public API | Internal API |
| ID format | Auto-increment | UUID | Internal, sort by ID | Distributed, bảo mật |
| Delete | Hard | Soft | GDPR, dữ liệu rác | Audit trail, khôi phục |
| Rate limit | Per-IP | Per-API-key | Public không auth | API 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ó
Deprecationheader vàSunsetdate - [ ] Changelog cho mỗi version
Pagination & Performance
- [ ] Mọi list endpoint có pagination bắt buộc
- [ ]
limitcó 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_morehoặcnext_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:
- Lấy danh sách khoá học
- Tạo khoá học mới
- Lấy bài học trong khoá học 42
- Học viên đăng ký khoá học 42
- 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ó paginationBài 2: Xây dựng error handler thống nhất
🧠 Quiz
Viết exception handler cho FastAPI đảm bảo:
- Lỗi validation (422) trả danh sách field lỗi cụ thể
- Lỗi 404 mô tả resource nào không tìm thấy
- Lỗi 500 không expose thông tin nội bộ, chỉ trả
request_id - 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:
- Sort theo
created_at DESC - Cursor mã hoá
(created_at, id)để xử lý trùng timestamp - Filter theo
status - Trả
totalchỉ khiinclude_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.