Data API 3 — Validation & serialization với Pydantic

13 thg 7, 2026 3 lượt xem
#python
#fastapi
#pydantic
#validation
#serialization

bài trước ta đã dựng được endpoint FastAPI đầu tiên và thấy Pydantic xuất hiện ở request body lẫn response_model. Bài này đào sâu vào chính lớp đó. Với một service dữ liệu ngân hàng, validation không phải là "cho vui": một số tiền sai dấu, một mã tiền tệ lạ, hay một cột nội bộ bị lộ ra ngoài API đều là sự cố thật. Pydantic biến type hint Python thành một hàng rào kiểm tra dữ liệu ở biên hệ thống — dữ liệu bẩn bị chặn trước khi chạm vào logic nghiệp vụ hay database.

Toàn bộ bài dùng Pydantic v2 (bản đi kèm FastAPI hiện đại). Code là minh hoạ, không chạy trực tiếp trong sandbox.

1. BaseModel: hàng rào ở biên

Mọi thứ bắt đầu từ BaseModel. Bạn khai báo field bằng type hint, Pydantic lo phần còn lại: ép kiểu (coercion), kiểm tra ràng buộc, và báo lỗi có cấu trúc nếu dữ liệu sai.

from datetime import datetime
from pydantic import BaseModel

class TransactionIn(BaseModel):
    account_id: int
    amount: float
    kind: str
    created_at: datetime | None = None

Quy tắc field:

Khai báoÝ nghĩa
account_id: intBắt buộc, phải ép được về int
created_at: datetime | None = NoneOptional, mặc định None
kind: str = "debit"Có default → không bắt buộc gửi lên
tags: list[str] = []Với mutable default, Pydantic tự tạo bản sao mỗi instance (an toàn, khác list default của hàm Python)

Điểm quan trọng: field không có default là bắt buộc. Nếu client không gửi account_id, Pydantic ném lỗi validation chứ không âm thầm gán None. Đây là khác biệt lớn so với dict thô — bạn không bao giờ phải viết data.get("account_id") rồi tự đoán.

Pydantic v2 mặc định ép kiểu "hợp lý": chuỗi "123"int 123, chuỗi ISO → datetime. Nếu muốn nghiêm ngặt hơn (không ép), dùng model_config = ConfigDict(strict=True) hoặc kiểu StrictInt.

2. Field(): ràng buộc và metadata

Type hint nói kiểu, còn Field() nói ràng buộc. Đây là nơi mã hoá luật dữ liệu ngân hàng.

from decimal import Decimal
from pydantic import BaseModel, Field

class TransferRequest(BaseModel):
    amount: Decimal = Field(gt=0, le=Decimal("1000000000"),
                            description="Số tiền chuyển, đơn vị VND")
    account_no: str = Field(min_length=6, max_length=20,
                            pattern=r"^\d+$",
                            description="Số tài khoản, chỉ chứa chữ số")
    note: str | None = Field(default=None, max_length=210)
    idempotency_key: str = Field(alias="Idempotency-Key")

Các ràng buộc hay dùng:

Tham sốÁp dụng choVí dụ ngân hàng
gt, ge, lt, lesốamount phải gt=0
min_length, max_lengthchuỗi/listsố tài khoản 6–20 ký tự
patternchuỗi (regex)mã tiền tệ, số tài khoản
aliasmọi fieldmap Idempotency-Key (header) sang tên Python
descriptionmọi fieldhiển thị trong tài liệu OpenAPI

description không chỉ để đẹp — nó chảy thẳng vào schema OpenAPI, nên trang /docs tự có chú thích cho từng field. Với alias, nhớ bật model_config = ConfigDict(populate_by_name=True) nếu muốn khởi tạo được cả bằng tên Python lẫn alias.

3. Kiểu nâng cao: đúng kiểu là nửa việc validation

Chọn đúng kiểu giúp Pydantic tự validate mà không cần viết thêm luật.

  • Decimal cho tiền tệ — đây là điểm sống còn. float dùng dấu phẩy động nhị phân, 0.1 + 0.2 ra 0.30000000000000004. Với số dư và giao dịch, sai số này tích luỹ thành lệch sổ. Luôn dùng Decimal, và có thể ràng buộc số chữ số thập phân bằng condecimal(max_digits=18, decimal_places=2).
  • EmailStr — validate định dạng email (cần cài pydantic[email]).
  • datetime, date — parse chuỗi ISO 8601 tự động, giữ timezone.
  • Enum — giới hạn giá trị trong tập cố định. Lý tưởng cho currency, kind, trạng thái.
  • UUID — cho ID giao dịch/định danh, chặn chuỗi rác ngay từ đầu.
from decimal import Decimal
from enum import Enum
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field

class Currency(str, Enum):
    VND = "VND"
    USD = "USD"
    EUR = "EUR"

class TxnKind(str, Enum):
    debit = "debit"
    credit = "credit"

class Transaction(BaseModel):
    txn_id: UUID
    account_id: int
    amount: Decimal = Field(gt=0)
    currency: Currency = Currency.VND
    kind: TxnKind
    email: EmailStr | None = None
    created_at: datetime

Với Enum kế thừa str, giá trị JSON gửi lên/xuống vẫn là chuỗi "VND", nhưng trong code bạn có Currency.VND được kiểm tra chặt. Client gửi "vnd" hay "XYZ" → 422 ngay.

4. Validator: luật nghiệp vụ không nhét vừa vào Field

Có những luật Field() không diễn tả được: quan hệ giữa các field, chuẩn hoá dữ liệu, hay kiểm tra phức tạp. Đó là việc của validator.

field_validator — chạy trên một field:

from pydantic import BaseModel, field_validator
from decimal import Decimal

ALLOWED = {"VND", "USD", "EUR"}

class TransferRequest(BaseModel):
    amount: Decimal
    currency: str

    @field_validator("currency")
    @classmethod
    def currency_uppercase_allowed(cls, v: str) -> str:
        v = v.strip().upper()
        if v not in ALLOWED:
            raise ValueError(f"currency phải thuộc {ALLOWED}")
        return v

    @field_validator("amount")
    @classmethod
    def amount_positive(cls, v: Decimal) -> Decimal:
        if v <= 0:
            raise ValueError("amount phải lớn hơn 0")
        return v

Lưu ý: validator vừa kiểm tra vừa chuẩn hoá (trả về giá trị đã upper()). Ném ValueError (hoặc AssertionError) là Pydantic tự gom vào lỗi 422 — không cần raise HTTPException thủ công.

model_validator — chạy trên toàn model, thấy được mọi field cùng lúc. Dùng cho luật liên field:

from pydantic import BaseModel, model_validator

class TransferRequest(BaseModel):
    from_account: str
    to_account: str

    @model_validator(mode="after")
    def different_accounts(self):
        if self.from_account == self.to_account:
            raise ValueError("Không thể chuyển cho chính tài khoản nguồn")
        return self

mode="after" chạy sau khi từng field đã validate xong (nhận self đầy đủ kiểu). mode="before" chạy trên dữ liệu thô (dict) trước khi ép kiểu — hữu ích khi cần gộp/tách field đầu vào.

5. Nested model và list

Dữ liệu thật hiếm khi phẳng. Pydantic lồng model tự nhiên, validate đệ quy toàn bộ cây.

from pydantic import BaseModel
from decimal import Decimal

class LineItem(BaseModel):
    account_no: str
    amount: Decimal

class BatchTransfer(BaseModel):
    batch_id: str
    items: list[LineItem]           # list các model con
    total: Decimal

    @model_validator(mode="after")
    def total_matches(self):
        if sum(i.amount for i in self.items) != self.total:
            raise ValueError("total không khớp tổng các item")
        return self

Nếu một item trong list sai, thông báo lỗi 422 chỉ rõ vị trí, ví dụ body.items.2.amount. Đây là lý do nên khai báo model lồng thay vì list[dict].

6. Serialization: model_dump và response_model

Validation lo dữ liệu vào, serialization lo dữ liệu ra. Hai chiều, hai mối quan tâm khác nhau.

model_dump() chuyển model thành dict; model_dump_json() thành chuỗi JSON. Vài tuỳ chọn hữu ích:

txn.model_dump()                       # dict đầy đủ
txn.model_dump(exclude={"internal_score"})   # bỏ field
txn.model_dump(exclude_none=True)      # bỏ field None
txn.model_dump(by_alias=True)          # dùng alias làm key
txn.model_dump(mode="json")            # Decimal/UUID/datetime → dạng JSON-safe

Nhưng ở tầng API, công cụ chính là response_model. Khi bạn khai báo response_model=TransactionOut, FastAPI lọc dữ liệu handler trả về theo đúng schema đó — field nào không có trong TransactionOut sẽ bị cắt, kể cả khi object gốc có. Đây là cơ chế chống lộ dữ liệu thừa mạnh nhất:

class TransactionOut(BaseModel):
    txn_id: UUID
    amount: Decimal
    currency: Currency
    created_at: datetime
    # KHÔNG có internal_risk_score, KHÔNG có raw_note nội bộ

@router.get("/transactions/{txn_id}", response_model=TransactionOut)
def get_txn(txn_id: UUID):
    row = repo.fetch(txn_id)   # object có 20 cột, gồm cả cột nhạy cảm
    return row                 # FastAPI chỉ trả 4 field của TransactionOut

Nguyên tắc vàng: đừng bao giờ tin object trả về sẽ tự sạch. response_model là hàng rào cuối. Nếu quên nó và return row thô, mọi cột nội bộ có thể rò ra JSON.

7. Computed field

Đôi khi response cần một giá trị dẫn xuất, tính từ các field khác. Dùng @computed_field:

from pydantic import BaseModel, computed_field
from decimal import Decimal

class AccountOut(BaseModel):
    balance: Decimal
    hold_amount: Decimal

    @computed_field
    @property
    def available_balance(self) -> Decimal:
        return self.balance - self.hold_amount

available_balance không nằm trong dữ liệu vào nhưng xuất hiện trong model_dump() và JSON trả về. Nó cũng vào schema OpenAPI, nên client thấy được field này trong tài liệu.

8. Lỗi validation 422 và cấu trúc lỗi

Khi validation fail, FastAPI trả HTTP 422 Unprocessable Entity với body JSON có cấu trúc chuẩn:

{
  "detail": [
    {
      "type": "greater_than",
      "loc": ["body", "amount"],
      "msg": "Input should be greater than 0",
      "input": -50000
    }
  ]
}

loc chỉ chính xác vị trí lỗi (đến tận body.items.2.amount cho list lồng), type là mã lỗi máy đọc được, msg là mô tả. Client (hay frontend) có thể map loc về đúng ô nhập liệu. Nếu cần định dạng lỗi riêng của tổ chức, ghi đè bằng @app.exception_handler(RequestValidationError).

9. Tách schema: request vs response vs DB model

Đây là nguyên tắc kiến trúc quan trọng nhất của bài, liên quan trực tiếp tới OOP & clean codephân quyền truy cập dữ liệu. Đừng dùng chung một model cho mọi tầng. Ba tầng, ba trách nhiệm:

  • Request schema (...Create, ...Update): những gì client được phép gửi. Không chứa id, created_at, internal_score — đó là những thứ server tự sinh.
  • Response schema (...Out): những gì client được phép thấy. Không chứa cột nội bộ, không lộ khoá ngoại nhạy cảm.
  • DB model: ánh xạ đầy đủ bảng, gồm mọi cột nội bộ. Thứ này không bao giờ ra thẳng API.
class CustomerCreate(BaseModel):        # client gửi
    full_name: str
    email: EmailStr
    city: str

class CustomerOut(BaseModel):           # client nhận
    id: int
    full_name: str
    city: str
    # KHÔNG có email nếu chính sách không cho lộ, KHÔNG có internal_kyc_flag

# DB model (SQLAlchemy) có thêm: internal_kyc_flag, risk_tier, raw_source...

Việc tách này chặn hai lỗi kinh điển cùng lúc: mass assignment (client nhét is_admin=true vào body update) và over-exposure (lộ cột nội bộ trong response). Sơ đồ bên dưới thể hiện luồng ba tầng.

10. Settings từ env với pydantic-settings

Cùng dòng máu Pydantic, pydantic-settings (gói riêng, pip install pydantic-settings) đọc cấu hình từ biến môi trường và validate như một model bình thường. Không hardcode secret trong code.

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="NCB_")

    database_url: str
    jwt_secret: str = Field(min_length=32)
    max_page_size: int = 100
    debug: bool = False

settings = Settings()   # đọc NCB_DATABASE_URL, NCB_JWT_SECRET... từ env

Nếu thiếu NCB_DATABASE_URL hoặc jwt_secret ngắn hơn 32 ký tự, ứng dụng fail ngay lúc khởi động thay vì chạy được rồi mới sập giữa chừng. Đây là "fail fast" — sai cấu hình bị phát hiện lúc deploy, không phải lúc khách hàng đang chuyển tiền. Chi tiết dùng secret cho JWT sẽ ở bài bảo mật.

Use case thực tế

Bối cảnh: NCB mở endpoint POST /transfers cho app mobile khởi tạo lệnh chuyển khoản nội bộ. Trước khi có schema chặt, team gặp ba sự cố trong một quý: (1) một lệnh amount = -2000000 lọt qua và ghi âm số dư; (2) currency = "vnd" viết thường làm job đối soát cuối ngày lệch; (3) response vô tình trả cả cột internal_risk_score mà đội rủi ro không muốn lộ ra client.

Giải pháp bằng Pydantic, 4 lớp phòng thủ:

  1. TransferRequest.amount: Decimal = Field(gt=0) — chặn số âm và số 0 ngay ở biên, trả 422. Dùng Decimal nên không lệch số dư do sai số float.
  2. currency: Currency (Enum) + field_validator chuẩn hoá upper()"vnd" được chuẩn hoá về "VND", "XYZ" bị từ chối.
  3. model_validator kiểm tra from_account != to_account — chặn chuyển vòng cho chính mình.
  4. response_model=TransferOut không khai báo internal_risk_score — FastAPI tự cắt, cột nội bộ không còn cách nào rò ra.

Kết quả: sau khi triển khai, ba nhóm lỗi trên về 0. Quan trọng hơn, mọi lỗi đầu vào giờ trả 422 có loc rõ ràng, đội frontend map thẳng về ô nhập, giảm thời gian xử lý ticket "chuyển tiền báo lỗi mà không biết vì sao". Toàn bộ ràng buộc nằm trong schema — đọc file schema là hiểu luật nghiệp vụ, không phải lần theo hàng chục if rải rác trong handler.

Ghi nhớ

  • Pydantic v2 biến type hint thành hàng rào validation ở biên; field không có default là bắt buộc.
  • Field() mã hoá ràng buộc (gt, min_length, pattern, alias, description); description chảy vào tài liệu OpenAPI.
  • Dùng Decimal cho mọi giá trị tiền tệfloat gây lệch sổ. Kèm Enum cho currency/kind, UUID cho ID, EmailStr cho email.
  • field_validator cho luật một field (kèm chuẩn hoá), model_validator cho luật liên field; ném ValueError là tự thành 422.
  • response_model là hàng rào cuối chống lộ dữ liệu — cắt mọi field không khai báo, kể cả khi object gốc có.
  • @computed_field cho giá trị dẫn xuất (vd available_balance) trong response.
  • Lỗi validation → HTTP 422 với detailloc/type/msg, chỉ chính xác vị trí lỗi.
  • Tách schema request / response / DB model để chặn mass assignment và over-exposure cùng lúc.
  • pydantic-settings đọc cấu hình từ env và validate — fail fast khi thiếu, không hardcode secret.

Bài viết liên quan

Vì sao Python là ngôn ngữ số một của data engineer: vai trò trong pipeline (ingest/transform/orchestrate), hệ sinh thái thư viện (pandas/polars/pyarrow/sqlalchemy), quản lý môi trường (venv/uv/poetry), và khi nào dùng Python vs SQL/Spark.

13 thg 7, 2026 4

Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.

13 thg 7, 2026 3

Lớp, kế thừa, đa hình, dunder methods, dataclass, type hints và nguyên tắc viết code sạch.

13 thg 7, 2026 3

Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.

13 thg 7, 2026 3