Data API 2 — FastAPI căn bản
Sau bài tổng quan REST API, giờ ta bắt tay vào framework cụ thể. FastAPI là lựa chọn mặc định của nhiều team dữ liệu Việt Nam khi cần dựng một service HTTP: nhanh, gõ ít code, và quan trọng nhất với dân data — nó tự sinh tài liệu API và tự validate dữ liệu đầu vào dựa trên type hint. Bài này đi qua toàn bộ nền tảng để bạn có thể tự tin viết một endpoint hoàn chỉnh trước khi qua các bài chuyên sâu về validation, database và bảo mật.
1. Cài đặt và khởi tạo
FastAPI là thư viện ASGI, cần một máy chủ ASGI để chạy. Bộ đôi phổ biến là fastapi + uvicorn.
pip install "fastapi[standard]" uvicorn
File tối thiểu main.py:
from fastapi import FastAPI
app = FastAPI(
title="NCB Data API",
description="Dịch vụ tra cứu dữ liệu giao dịch cho hệ thống nội bộ",
version="1.0.0",
)
@app.get("/health")
def health():
return {"status": "ok"}
Chạy môi trường dev với reload tự động khi sửa code:
uvicorn main:app --reload --host 0.0.0.0 --port 8000
Cú pháp main:app nghĩa là "trong module main, lấy đối tượng tên app". Cờ --reload chỉ dùng cho dev — không bao giờ bật ở production (ta sẽ bàn ở bài triển khai). Đối tượng FastAPI() là trung tâm: mọi route, middleware, cấu hình OpenAPI đều gắn vào nó.
2. Path operation và decorator
Một path operation là cặp (đường dẫn, phương thức HTTP) ánh xạ tới một hàm xử lý. FastAPI dùng decorator để khai báo:
| Decorator | Ý nghĩa |
|---|---|
@app.get(path) | Đọc dữ liệu (idempotent) |
@app.post(path) | Tạo tài nguyên mới |
@app.put(path) | Thay thế toàn bộ tài nguyên |
@app.patch(path) | Cập nhật một phần |
@app.delete(path) | Xóa tài nguyên |
@app.get("/accounts")
def list_accounts():
return [{"id": 1, "account_no": "0011000123456"}]
Hàm bên dưới decorator gọi là path operation function. Giá trị nó return sẽ được FastAPI serialize thành JSON. Bạn trả về dict, list, model Pydantic, hay kiểu cơ bản — framework tự chuyển đổi.
3. Path parameter và query parameter
Path parameter
Tham số nằm trong chính đường dẫn, khai báo bằng dấu ngoặc nhọn. Khai báo type hint để FastAPI tự ép kiểu và validate:
@app.get("/accounts/{account_id}")
def get_account(account_id: int):
return {"account_id": account_id}
Gọi /accounts/42 → account_id = 42 (int). Gọi /accounts/abc → FastAPI tự trả về lỗi 422 Unprocessable Entity kèm mô tả rõ ràng, hàm của bạn không bao giờ nhận rác.
Query parameter
Mọi tham số của hàm không khớp với path parameter sẽ được hiểu là query parameter (phần sau dấu ? trên URL):
from typing import Optional
@app.get("/transactions")
def search_transactions(
limit: int = 20, # có mặc định → optional
offset: int = 0,
kind: Optional[str] = None, # có thể vắng mặt
):
return {"limit": limit, "offset": offset, "kind": kind}
Quy tắc:
- Có giá trị mặc định → tham số tùy chọn.
/transactionsdùnglimit=20. - Không có mặc định → bắt buộc. Thiếu thì
422. Optional[str] = None(tương đươngstr | None = None) → cho phép vắng mặt và mang giá trịNone.
Để ràng buộc chặt hơn (khoảng giá trị, độ dài, regex), dùng Query:
from fastapi import Query
@app.get("/transactions")
def search(limit: int = Query(default=20, ge=1, le=100)):
return {"limit": limit}
ge=1, le=100 đảm bảo limit nằm trong [1, 100] — chặn ngay các query "kéo cả bảng" gây quá tải.
4. Request body với Pydantic BaseModel
Với POST/PUT, dữ liệu đi trong body. FastAPI dùng Pydantic để mô tả và validate body. Khai báo một BaseModel và dùng nó làm type hint:
from pydantic import BaseModel, Field
class TransferRequest(BaseModel):
from_account: str = Field(..., min_length=10, max_length=20)
to_account: str = Field(..., min_length=10, max_length=20)
amount: float = Field(..., gt=0)
note: str | None = None
@app.post("/transfers")
def create_transfer(payload: TransferRequest):
return {"received": payload.amount}
Khi request tới, FastAPI: đọc JSON body → validate theo TransferRequest → nếu sai kiểu/thiếu trường/vi phạm ràng buộc thì trả 422 với chi tiết; nếu hợp lệ, đưa vào hàm một object payload đã được ép kiểu sẵn. Dấu ... trong Field(...) nghĩa là trường bắt buộc. Chi tiết sâu về validation nằm ở bài validation dữ liệu.
5. response_model và status_code
Bạn nên khai báo rõ kiểu dữ liệu trả về bằng response_model. Lợi ích: (1) lọc bỏ các field không nên lộ ra (ví dụ số dư nội bộ), (2) tài liệu OpenAPI mô tả chính xác output, (3) FastAPI tự validate cả output.
from fastapi import status
class AccountOut(BaseModel):
id: int
account_no: str
currency: str
# KHÔNG khai báo balance → sẽ bị loại khỏi response
@app.post(
"/accounts",
response_model=AccountOut,
status_code=status.HTTP_201_CREATED,
)
def create_account(payload: TransferRequest):
fake = {"id": 1, "account_no": "0011...", "currency": "VND", "balance": 999}
return fake # balance tự động bị lược bỏ nhờ response_model
status_code=201 báo đúng ngữ nghĩa "đã tạo tài nguyên". Mặc định GET trả 200.
6. async def hay def?
Đây là điểm hay gây bối rối. FastAPI hỗ trợ cả hai kiểu hàm handler:
async def: dùng khi bên trong bạn gọi thư viện bất đồng bộ (await), ví dụhttpx.AsyncClient, driver DB async nhưasyncpg,databases. Handler chạy trực tiếp trên event loop.def(thường): dùng khi code là đồng bộ chặn (blocking) —psycopg2,requests,pandas, đọc file. FastAPI tự động chạy các hàmdeftrong một threadpool riêng, nên chúng không chặn event loop.
import httpx
@app.get("/rate")
async def get_rate():
async with httpx.AsyncClient() as client: # gọi async → await
r = await client.get("https://example/fx")
return r.json()
@app.get("/report")
def build_report():
df = read_big_csv() # pandas blocking → để def, chạy trong threadpool
return {"rows": len(df)}
Sai lầm nguy hiểm nhất: đặt code blocking bên trong async def. Khi đó nó chạy thẳng trên event loop và chặn toàn bộ server — mọi request khác phải chờ. Quy tắc an toàn: nếu không có await nào trong hàm, hãy dùng def thường. Chủ đề tối ưu async/performance có bài riêng và liên hệ với SQL, API & Async trong Python.
7. Tài liệu OpenAPI tự sinh
Đây là điểm mạnh lớn nhất của FastAPI. Từ type hint và Pydantic model, nó tự sinh OpenAPI schema (JSON tại /openapi.json) và hai giao diện tài liệu tương tác:
- Swagger UI tại
/docs— cho phép gõ tham số và bấm "Try it out" gọi thử API ngay trên trình duyệt. - ReDoc tại
/redoc— giao diện đọc, phù hợp làm tài liệu chia sẻ.
Không cần viết thêm dòng nào. Với data team, nghĩa là cán bộ nghiệp vụ có thể tự thử API mà không cần bạn giải thích từng tham số.
Làm tài liệu đẹp hơn
Bạn có thể bổ sung tags, summary, mô tả và ví dụ cho từng endpoint:
@app.get(
"/accounts/{account_id}/transactions",
tags=["transactions"],
summary="Lịch sử giao dịch của một tài khoản",
response_description="Danh sách giao dịch đã phân trang",
)
def list_txn(account_id: int):
...
tags gom các endpoint thành nhóm trong Swagger UI. summary là dòng tiêu đề ngắn. Docstring của hàm trở thành mô tả chi tiết (hỗ trợ Markdown). Ta có thể gắn examples vào field Pydantic để tài liệu hiển thị mẫu dữ liệu thật:
class TransferRequest(BaseModel):
amount: float = Field(..., gt=0, examples=[500000])
Bài kiểm thử và tài liệu đi sâu hơn về phần này.
8. APIRouter — chia module
Khi service lớn dần, dồn mọi route vào main.py sẽ rối. APIRouter cho phép tách route theo domain rồi gắn vào app chính:
# routers/transactions.py
from fastapi import APIRouter
router = APIRouter(prefix="/accounts", tags=["transactions"])
@router.get("/{account_id}/transactions")
def list_txn(account_id: int):
return []
# main.py
from fastapi import FastAPI
from routers import transactions
app = FastAPI()
app.include_router(transactions.router)
prefix được thêm vào mọi đường dẫn trong router, tags áp cho cả nhóm. Nhờ vậy mỗi domain (accounts, transactions, customers) nằm ở một file riêng, dễ bảo trì — tinh thần tách trách nhiệm giống OOP & clean code.
9. Xử lý lỗi với HTTPException
Khi muốn trả lỗi HTTP có ngữ nghĩa (không tìm thấy, không có quyền...), raise HTTPException:
from fastapi import HTTPException, status
@app.get("/accounts/{account_id}")
def get_account(account_id: int):
account = db_lookup(account_id) # minh hoạ
if account is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Không tìm thấy tài khoản {account_id}",
)
return account
FastAPI bắt exception này và trả về {"detail": "..."} với đúng status code. Với logic phức tạp hơn (custom exception handler, response lỗi thống nhất) sẽ bàn ở các bài sau. Phần xác thực/phân quyền nằm ở bài auth & bảo mật.
10. Cấu trúc project gợi ý
Bố cục thư mục thực dụng cho một data service cỡ vừa:
ncb-data-api/
├── app/
│ ├── main.py # tạo FastAPI(), include_router
│ ├── routers/
│ │ ├── accounts.py
│ │ └── transactions.py
│ ├── schemas/ # Pydantic BaseModel (in/out)
│ │ └── transaction.py
│ ├── services/ # logic nghiệp vụ, truy vấn DB
│ └── core/ # config, kết nối DB, dependency
└── tests/
Tách schemas (hình dạng dữ liệu), services (nghiệp vụ), routers (giao thức HTTP) giúp mỗi lớp thay đổi độc lập — DB đổi không kéo theo sửa API. Việc tích hợp DB thật là nội dung bài database integration.
Use case thực tế
Bài toán: Ứng dụng Internet Banking của NCB cần một endpoint trả lịch sử giao dịch của một tài khoản, có phân trang để màn hình chỉ tải 20 giao dịch mỗi lần thay vì kéo cả chục nghìn dòng.
Thiết kế: GET /accounts/{account_id}/transactions?limit=20&offset=0
account_id: path parameter (bắt buộc, kiểuint).limit: query parameter, mặc định 20, ràng buộc 1–100 để tránh quá tải.offset: query parameter, mặc định 0, dùng để nhảy trang.- Trả về
response_modelchứa metadata phân trang + danh sách giao dịch.
Endpoint hoàn chỉnh (minh hoạ — không kết nối DB thật):
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel
router = APIRouter(prefix="/accounts", tags=["transactions"])
class TransactionOut(BaseModel):
id: int
amount: float
kind: str
created_at: datetime
class TransactionPage(BaseModel):
account_id: int
total: int # tổng số giao dịch của tài khoản
limit: int
offset: int
items: list[TransactionOut]
@router.get(
"/{account_id}/transactions",
response_model=TransactionPage,
summary="Lịch sử giao dịch của một tài khoản (có phân trang)",
response_description="Trang giao dịch kèm metadata phân trang",
)
def list_transactions(
account_id: int,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
"""
Trả về lịch sử giao dịch của `account_id`, sắp xếp mới nhất trước.
Dùng `limit`/`offset` để phân trang.
"""
account = fake_get_account(account_id) # minh hoạ
if account is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Không tìm thấy tài khoản {account_id}",
)
total = fake_count_transactions(account_id) # minh hoạ
rows = fake_query_transactions( # minh hoạ
account_id=account_id, limit=limit, offset=offset
)
return TransactionPage(
account_id=account_id,
total=total,
limit=limit,
offset=offset,
items=rows,
)
Về mặt truy vấn, phần lõi khi nối DB thật (PostgreSQL) tương đương câu SQL sau — đây đúng là loại truy vấn chạy được trên sandbox chỉ-đọc của ta:
-- ▶ Chạy được
SELECT id, amount, kind, created_at
FROM transactions
WHERE account_id = 1
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
Kết quả: màn hình lịch sử tải nhanh, mỗi lần chỉ trả ~20 dòng; client biết total để hiển thị "Trang 1/N"; ràng buộc limit ≤ 100 chặn các request cố kéo toàn bộ dữ liệu. Cùng một endpoint phục vụ được cả app di động lẫn màn hình quầy giao dịch.
Ghi nhớ
app = FastAPI()là trung tâm; chạy dev bằnguvicorn main:app --reload(không dùng--reloadở production).- Decorator
@app.get/post/put/patch/deletekhai báo path operation; giá trịreturnđược serialize thành JSON. - Path parameter nằm trong
{}của đường dẫn; query parameter là tham số hàm không khớp path — có mặc định thì tùy chọn, không có thì bắt buộc. - Request body mô tả bằng Pydantic
BaseModel; FastAPI tự validate và trả422khi sai — không cần viết code kiểm tra thủ công. - Luôn khai báo
response_modelđể lọc field nhạy cảm và tài liệu hóa output chính xác; đặtstatus_codeđúng ngữ nghĩa (ví dụ201khi tạo). - Dùng
async defkhi cóawait; dùngdefthường cho code blocking (nó chạy trong threadpool). Không đặt code blocking trongasync def. - OpenAPI/Swagger UI (
/docs) và ReDoc (/redoc) sinh tự động — điểm mạnh lớn giúp người dùng tự thử API. APIRoutertách route theo domain;HTTPExceptiontrả lỗi HTTP có ngữ nghĩa (ví dụ404).- Phân trang bằng
limit/offsetvới ràng buộc khoảng giá trị là cách chuẩn để tránh kéo toàn bộ dữ liệu.
Bài viết liên quan
Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.
Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.
Lớp, kế thừa, đa hình, dunder methods, dataclass, type hints và nguyên tắc viết code sạch.
Kết nối CSDL với SQLAlchemy, gọi REST API với requests/httpx, và lập trình bất đồng bộ asyncio.