FastAPI: dựng REST API có validation tự động
1. Tổng quan
Khi viết REST API, phần lớn code “phòng thủ” nằm ở việc kiểm tra input: trường có bắt buộc không, số có âm không, email có hợp lệ không. FastAPI giao việc này cho Pydantic. Bạn chỉ cần khai báo kiểu dữ liệu và ràng buộc, framework sẽ tự validate request và trả lỗi 422 có cấu trúc.
Bài này dựng một API quản lý đơn hàng (Order) từ đầu. Đọc xong, bạn sẽ biết cách ràng buộc body, path và query. Bạn cũng sẽ viết được validator tùy biến, lọc dữ liệu trả về, đổi định dạng lỗi và test toàn bộ bằng pytest.
2. Cài đặt
Bạn cần Python 3.10 trở lên. Hãy tạo virtualenv rồi cài FastAPI kèm nhóm dependency standard:
python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" pytest
Gói fastapi[standard] đã có sẵn những thứ bài này cần:
uvicornđể chạy server.fastapi-clicung cấp lệnhfastapi dev.httpxdùng choTestClient.email-validatordùng cho kiểuEmailStr.
3. Endpoint đầu tiên với Pydantic model
Tạo file main.py:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="Order API")
class OrderCreate(BaseModel):
customer_email: str
product_sku: str
quantity: int
@app.post("/orders", status_code=201)
def create_order(order: OrderCreate) -> OrderCreate:
return order
Chạy server ở chế độ dev, có tự reload:
fastapi dev main.py
Ở đây chưa có dòng if nào. Tham số order: OrderCreate báo cho FastAPI ba việc:
- Đọc JSON từ request body.
- Parse JSON đó thành
OrderCreate. - Trả
422nếu dữ liệu không khớp kiểu.
Thử gửi quantity là một chuỗi không phải số:
curl -X POST localhost:8000/orders \
-H "Content-Type: application/json" \
-d '{"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":"abc"}'
{"detail":[{"type":"int_parsing","loc":["body","quantity"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}
Trường loc chỉ đúng vị trí lỗi: nằm trong body, ở trường quantity. Thêm vào đó, tài liệu OpenAPI tại /docs được sinh từ chính model này. Code và tài liệu vì thế không bị lệch nhau.
4. Ràng buộc dữ liệu với Field
Kiểm tra kiểu là chưa đủ. Ví dụ quantity = -5 vẫn là int hợp lệ. Bạn thêm ràng buộc bằng Field, kết hợp với Annotated:
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field
Sku = Annotated[str, Field(min_length=3, max_length=20, pattern=r"^[A-Za-z0-9-]+$")]
class OrderCreate(BaseModel):
customer_email: EmailStr
product_sku: Sku
quantity: Annotated[int, Field(gt=0, le=100)]
unit_price: Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
discount: Annotated[Decimal, Field(ge=0, decimal_places=2)] = Decimal("0")
note: Annotated[str | None, Field(max_length=200)] = None
Từng dòng làm những việc sau:
EmailStrkiểm tra định dạng email thật sự, không chỉ kiểm tra có ký tự@.Skulà một kiểu tái sử dụng được. Nó yêu cầu độ dài 3–20 ký tự và chỉ chứa chữ, số, dấu gạch ngang.quantityphải lớn hơn 0 và không vượt quá 100.unit_pricedùngDecimalthay chofloatđể tránh lỗi làm tròn khi tính tiền. Giá trị tối đa 10 chữ số, 2 chữ số thập phân.discountvànotecó giá trị mặc định nên là trường tùy chọn. Trường nào không có default là trường bắt buộc.
Gửi quantity: 0 sẽ nhận được:
{"type":"greater_than","loc":["body","quantity"],"msg":"Input should be greater than 0","input":0,"ctx":{"gt":0}}
Lưu ý: Mặc định Pydantic chạy ở chế độ lax. Chuỗi
"5"sẽ được ép thành số5mà không báo lỗi. Nếu muốn từ chối hẳn, hãy thêmmodel_config = ConfigDict(strict=True)vào model, hoặc dùngField(strict=True)cho từng trường.
5. Validate path và query parameter
Không chỉ body, path và query cũng được validate theo cùng một cơ chế:
from fastapi import HTTPException, Path, Query
orders_db: dict[int, dict] = {}
@app.get("/orders/{order_id}")
def get_order(order_id: Annotated[int, Path(gt=0)]):
if order_id not in orders_db:
raise HTTPException(status_code=404, detail="Order not found")
return orders_db[order_id]
@app.get("/orders")
def list_orders(
page: Annotated[int, Query(ge=1)] = 1,
size: Annotated[int, Query(ge=1, le=50)] = 10,
sku: Annotated[str | None, Query(min_length=3)] = None,
):
items = [o for o in orders_db.values() if sku is None or o["product_sku"] == sku]
start = (page - 1) * size
return items[start : start + size]
Kết quả với các request khác nhau:
GET /orders/0trả về422, vìorder_idphải lớn hơn 0.GET /orders?size=500trả về422, vìsizetối đa là 50.GET /orders?page=2&size=5chạy bình thường. Hai giá trịpagevàsizeđã được ép thànhinttrước khi vào hàm.
Hãy phân biệt 422 và 404. Lỗi 422 nghĩa là request sai định dạng. Lỗi 404 là logic nghiệp vụ, nên bạn tự raise HTTPException.
6. Validator tùy biến
Có những quy tắc không diễn đạt được bằng Field. Khi đó bạn dùng field_validator cho một trường, hoặc model_validator cho quy tắc liên quan nhiều trường.
from pydantic import field_validator, model_validator
FORBIDDEN_DOMAINS = {"mailinator.com", "tempmail.com"}
class OrderCreate(BaseModel):
# ... các trường như mục 4 ...
@field_validator("product_sku")
@classmethod
def normalize_sku(cls, value: str) -> str:
return value.upper()
@field_validator("customer_email")
@classmethod
def reject_disposable_email(cls, value: str) -> str:
domain = value.split("@")[-1].lower()
if domain in FORBIDDEN_DOMAINS:
raise ValueError("Disposable email is not allowed")
return value
@model_validator(mode="after")
def check_discount(self) -> "OrderCreate":
total = self.unit_price * self.quantity
if self.discount > total:
raise ValueError("Discount cannot exceed order total")
return self
Giải thích từng validator:
normalize_skuchạy sau khiFieldđã kiểm tra pattern. Nó chuẩn hóa SKU thành chữ hoa, nên"tshirt-01"được lưu thành"TSHIRT-01". Validator có thể biến đổi giá trị, miễn làreturngiá trị mới.reject_disposable_emailraiseValueError. Pydantic chuyển lỗi này thành lỗi validation, và message có tiền tố"Value error, ".check_discountchạy vớimode="after", tức là khi mọi trường đã hợp lệ và đã có kiểu đúng. Nhờ vậy bạn nhânDecimalan toàn. Nhớreturn self.
Lưu ý: Đừng gọi database hay API ngoài trong validator. Ví dụ, việc kiểm tra “SKU có tồn tại trong kho không” là logic nghiệp vụ. Hãy đặt nó trong service hoặc dependency. Validator nên là hàm thuần, nhanh, dễ test.
7. response_model: kiểm soát dữ liệu trả ra
Bạn thường lưu nhiều thông tin hơn mức muốn trả cho client, chẳng hạn ghi chú nội bộ. Hãy tách model input và output:
class OrderRead(BaseModel):
id: int
customer_email: EmailStr
product_sku: str
quantity: int
total: Decimal
@app.post("/orders", status_code=201, response_model=OrderRead)
def create_order(order: OrderCreate):
order_id = len(orders_db) + 1
record = {
"id": order_id,
**order.model_dump(),
"total": order.unit_price * order.quantity - order.discount,
"internal_flag": "manual-review",
}
orders_db[order_id] = record
return record
Hàm trả về một dict có cả note và internal_flag. Tuy vậy, response_model=OrderRead lọc response chỉ còn các trường đã khai báo:
{"id":1,"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":2,"total":"39.98"}
Có hai điểm cần để ý:
Decimalđược serialize thành chuỗi"39.98"để giữ nguyên độ chính xác. Client cần biết điều này.- Response cũng được validate. Nếu bạn quên trường
total, FastAPI némResponseValidationErrorvà trả500, không phải422. Đây là lỗi của server, không phải của client.
8. Tùy biến định dạng lỗi 422
Định dạng mặc định khá chi tiết. Nhiều team muốn một cấu trúc gọn và thống nhất cho frontend. Bạn ghi đè handler của RequestValidationError:
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = [
{
"field": ".".join(str(part) for part in err["loc"][1:]) or "body",
"message": err["msg"],
}
for err in exc.errors()
]
return JSONResponse(
status_code=422,
content={"code": "VALIDATION_FAILED", "errors": errors},
)
Cách handler xử lý từng lỗi:
err["loc"][1:]bỏ phần tử đầu (body,query,path) và nối phần còn lại bằng dấu chấm.- Lỗi từ
model_validatorkhông gắn với trường nào. Khi đólocchỉ còn("body",), nên handler gán"body".
Gửi một đơn có quantity: 0 và email dùng một lần sẽ nhận được:
{"code":"VALIDATION_FAILED","errors":[
{"field":"customer_email","message":"Value error, Disposable email is not allowed"},
{"field":"quantity","message":"Input should be greater than 0"}]}
Pydantic thu thập tất cả lỗi của các trường trong một lần, thay vì dừng ở lỗi đầu tiên. Client vì thế sửa được hết trong một vòng.
9. Test với pytest
TestClient gọi app trực tiếp, không cần chạy server. Tạo file test_main.py cạnh main.py:
import pytest
from fastapi.testclient import TestClient
from main import app, orders_db
client = TestClient(app)
VALID_ORDER = {
"customer_email": "an@example.com",
"product_sku": "tshirt-01",
"quantity": 2,
"unit_price": "19.99",
}
@pytest.fixture(autouse=True)
def clear_db():
orders_db.clear()
def test_create_order_returns_normalized_sku_and_total():
response = client.post("/orders", json=VALID_ORDER)
assert response.status_code == 201
body = response.json()
assert body["product_sku"] == "TSHIRT-01"
assert body["total"] == "39.98"
assert "internal_flag" not in body
def test_invalid_quantity_and_email_are_reported_together():
payload = {**VALID_ORDER, "quantity": 0, "customer_email": "x@mailinator.com"}
response = client.post("/orders", json=payload)
assert response.status_code == 422
fields = {e["field"] for e in response.json()["errors"]}
assert fields == {"quantity", "customer_email"}
def test_discount_greater_than_total_is_rejected():
payload = {**VALID_ORDER, "discount": "100.00"}
response = client.post("/orders", json=payload)
assert response.status_code == 422
assert response.json()["errors"][0] == {
"field": "body",
"message": "Value error, Discount cannot exceed order total",
}
def test_page_size_over_limit_is_rejected():
assert client.get("/orders?size=500").status_code == 422
Chạy test:
pytest -q
.... [100%]
4 passed in 0.31s
Fixture clear_db reset orders_db trước mỗi test, giúp các test độc lập với nhau. Ngoài ra, bạn có thể test riêng model mà không cần HTTP, bằng OrderCreate.model_validate(...) kết hợp pytest.raises(ValidationError). Cách này phù hợp khi model có nhiều quy tắc phức tạp.
10. Lỗi hay gặp
- Dùng
floatcho tiền.0.1 + 0.2không bằng0.3. Hãy dùngDecimalvớimax_digitsvàdecimal_places. - Quên
@classmethodhoặc thứ tự decorator.@field_validatorphải nằm trên@classmethod. Ngược lại, Pydantic v2 báo lỗi ngay lúc import. - Quên
returntrong validator.field_validatorkhông return sẽ gánNonecho trường.model_validator(mode="after")phải returnself. - Dùng cú pháp Pydantic v1.
@validator,class Config,.dict()đã bị deprecate. Hãy dùng@field_validator,model_config,.model_dump(). - Nhầm
422với500. Input sai cho ra422. Response không khớpresponse_modelcho ra500. Thấy500thì hãy kiểm tra dữ liệu bạn trả về. - Dùng chung một model cho input và output. Làm vậy dễ lộ trường nội bộ, và buộc client gửi các trường như
id. Hãy táchOrderCreatevàOrderRead.
Lưu ý: Validation ở tầng API không thay thế ràng buộc ở database. Các ràng buộc như
UNIQUEhayNOT NULLvẫn cần có. Request có thể đi vào hệ thống từ nhiều đường khác nhau, chẳng hạn job nền hoặc script migration.
11. Kết luận
FastAPI kết hợp với Pydantic biến type hint thành hợp đồng dữ liệu, có ba lợi ích chính:
- Request sai bị chặn từ cửa vào.
- Lỗi được trả về đầy đủ và có cấu trúc.
- Tài liệu OpenAPI luôn khớp với code.
Trong bài, bạn đã dùng Field cho ràng buộc đơn giản, Path và Query cho tham số URL, field_validator và model_validator cho quy tắc tùy biến. Bạn cũng đã dùng response_model để lọc output, đổi định dạng lỗi 422, và kiểm thử toàn bộ bằng TestClient.
Nguyên tắc cần nhớ: để validator làm việc kiểm tra hình dạng dữ liệu, còn logic nghiệp vụ đặt trong tầng service.