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-cli cung cấp lệnh fastapi dev.
  • httpx dùng cho TestClient.
  • email-validator dùng cho kiểu EmailStr.

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ả 422 nế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:

  • EmailStr kiểm tra định dạng email thật sự, không chỉ kiểm tra có ký tự @.
  • Sku là 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.
  • quantity phải lớn hơn 0 và không vượt quá 100.
  • unit_price dùng Decimal thay cho float để 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.
  • discount và note có 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ố 5 mà không báo lỗi. Nếu muốn từ chối hẳn, hãy thêm model_config = ConfigDict(strict=True) vào model, hoặc dùng Field(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/0 trả về 422, vì order_id phải lớn hơn 0.
  • GET /orders?size=500 trả về 422, vì size tối đa là 50.
  • GET /orders?page=2&size=5 chạy bình thường. Hai giá trị page và size đã được ép thành int trướ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_sku chạy sau khi Field đã 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à return giá trị mới.
  • reject_disposable_email raise ValueError. Pydantic chuyển lỗi này thành lỗi validation, và message có tiền tố "Value error, ".
  • check_discount chạy với mode="after", tức là khi mọi trường đã hợp lệ và đã có kiểu đúng. Nhờ vậy bạn nhân Decimal an 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ém ResponseValidationError và trả 500, không phải 422. Đâ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_validator không gắn với trường nào. Khi đó loc chỉ 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 float cho tiền. 0.1 + 0.2 không bằng 0.3. Hãy dùng Decimal với max_digits và decimal_places.
  • Quên @classmethod hoặc thứ tự decorator. @field_validator phải nằm trên @classmethod. Ngược lại, Pydantic v2 báo lỗi ngay lúc import.
  • Quên return trong validator. field_validator không return sẽ gán None cho trường. model_validator(mode="after") phải return self.
  • 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 422 với 500. Input sai cho ra 422. Response không khớp response_model cho ra 500. Thấy 500 thì 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ách OrderCreate và OrderRead.

Lưu ý: Validation ở tầng API không thay thế ràng buộc ở database. Các ràng buộc như UNIQUE hay NOT NULL vẫ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.

pythonfastapipydanticrest-apivalidation