02-2. 欄位驗證與約束

⏱️ 閱讀時間: 15 分鐘 🎯 難度: ⭐⭐ (基礎)


🤔 一句話解釋

Pydantic 提供豐富的內建驗證器,讓你用簡單的參數就能定義複雜的驗證規則。


🔢 數字驗證

基本約束

from pydantic import BaseModel, Field

class NumberConstraints(BaseModel):
    # 大於 (greater than)
    gt_example: int = Field(gt=0)       # > 0

    # 大於等於 (greater than or equal)
    ge_example: int = Field(ge=0)       # >= 0

    # 小於 (less than)
    lt_example: int = Field(lt=100)     # < 100

    # 小於等於 (less than or equal)
    le_example: int = Field(le=100)     # <= 100

    # 組合使用
    age: int = Field(ge=0, le=150)      # 0 <= age <= 150
    price: float = Field(gt=0, le=99999.99)

    # 倍數
    quantity: int = Field(multiple_of=5)  # 必須是 5 的倍數

# 測試
NumberConstraints(
    gt_example=1,
    ge_example=0,
    lt_example=99,
    le_example=100,
    age=25,
    price=99.99,
    quantity=15
)

實際應用

from pydantic import BaseModel, Field
from decimal import Decimal

class PaymentRequest(BaseModel):
    """付款請求"""
    amount: Decimal = Field(
        ...,
        gt=0,
        le=Decimal("1000000.00"),
        decimal_places=2,
        description="付款金額,最多 100 萬"
    )

    installments: int = Field(
        default=1,
        ge=1,
        le=24,
        description="分期期數,1-24 期"
    )

class DiscountCoupon(BaseModel):
    """折扣券"""
    discount_percent: int = Field(
        ...,
        ge=1,
        le=100,
        description="折扣百分比,1-100"
    )

    min_order_amount: Decimal = Field(
        default=Decimal("0"),
        ge=0,
        description="最低訂單金額"
    )

    max_discount: Decimal = Field(
        default=Decimal("1000"),
        gt=0,
        description="最高折抵金額"
    )

📝 字串驗證

長度約束

from pydantic import BaseModel, Field

class StringConstraints(BaseModel):
    # 最小長度
    username: str = Field(min_length=3)

    # 最大長度
    bio: str = Field(max_length=500)

    # 組合
    password: str = Field(min_length=8, max_length=128)

    # 固定長度(使用正規表達式)
    country_code: str = Field(pattern=r"^[A-Z]{2}$")  # 兩個大寫字母

正規表達式驗證

from pydantic import BaseModel, Field

class PatternValidation(BaseModel):
    # 電話號碼(台灣手機)
    phone: str = Field(
        pattern=r"^09\d{8}$",
        description="台灣手機號碼"
    )

    # 身分證字號
    taiwan_id: str = Field(
        pattern=r"^[A-Z][12]\d{8}$",
        description="台灣身分證字號"
    )

    # 郵遞區號
    zip_code: str = Field(
        pattern=r"^\d{3,5}$",
        description="郵遞區號"
    )

    # Slug(URL 友善字串)
    slug: str = Field(
        pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
        description="URL slug"
    )

    # 密碼(至少一個大寫、一個小寫、一個數字)
    strong_password: str = Field(
        pattern=r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$",
        description="強密碼"
    )

字串處理

from pydantic import BaseModel, Field
from typing import Annotated
from pydantic.functional_validators import AfterValidator

def normalize_email(v: str) -> str:
    return v.lower().strip()

def strip_whitespace(v: str) -> str:
    return v.strip()

class UserInput(BaseModel):
    # 使用 Annotated 添加處理
    email: Annotated[str, AfterValidator(normalize_email)]
    name: Annotated[str, AfterValidator(strip_whitespace)]

# 測試
user = UserInput(email="  JOHN@EXAMPLE.COM  ", name="  John Doe  ")
print(user.email)  # john@example.com
print(user.name)   # John Doe

📋 列表與集合驗證

長度約束

from pydantic import BaseModel, Field
from typing import List, Set

class CollectionConstraints(BaseModel):
    # 列表最小/最大長度
    tags: List[str] = Field(
        default_factory=list,
        min_length=0,
        max_length=10,
        description="標籤,最多 10 個"
    )

    # 集合
    categories: Set[str] = Field(
        default_factory=set,
        max_length=5,
        description="分類,最多 5 個"
    )

    # 非空列表
    items: List[int] = Field(
        ...,
        min_length=1,
        description="至少要有一個項目"
    )

列表元素驗證

from pydantic import BaseModel, Field
from typing import List
from typing_extensions import Annotated

class Order(BaseModel):
    # 列表中的每個元素都會被驗證
    quantities: List[int] = Field(
        ...,
        description="每個項目的數量"
    )

    # 使用 Annotated 對元素進行約束
    prices: List[Annotated[float, Field(gt=0)]] = Field(
        default_factory=list,
        description="價格列表,每個價格必須大於 0"
    )

📧 內建特殊型別

Email 驗證

from pydantic import BaseModel, EmailStr

class User(BaseModel):
    email: EmailStr  # 自動驗證 email 格式

# 測試
User(email="john@example.com")     # ✅
User(email="invalid-email")        # ❌ ValidationError

URL 驗證

from pydantic import BaseModel, HttpUrl, AnyUrl

class Links(BaseModel):
    # HTTP/HTTPS URL
    website: HttpUrl

    # 任意 URL(包括 ftp, file 等)
    resource: AnyUrl

# 測試
Links(
    website="https://example.com",
    resource="ftp://files.example.com/data"
)

其他特殊型別

from pydantic import (
    BaseModel,
    EmailStr,
    HttpUrl,
    IPvAnyAddress,
    SecretStr,
    Json,
    UUID4,
)
from typing import List

class SpecialTypes(BaseModel):
    # IP 地址
    ip_address: IPvAnyAddress

    # 密碼(不會在日誌中顯示)
    password: SecretStr

    # UUID v4
    user_id: UUID4

    # JSON 字串會被解析
    metadata: Json[dict]

# 使用 SecretStr
user = SpecialTypes(
    ip_address="192.168.1.1",
    password="secret123",
    user_id="550e8400-e29b-41d4-a716-446655440000",
    metadata='{"key": "value"}'
)

print(user.password)                    # SecretStr('**********')
print(user.password.get_secret_value()) # secret123

🎛️ 條件約束

使用 Literal 限制值

from pydantic import BaseModel
from typing import Literal

class Config(BaseModel):
    environment: Literal["development", "staging", "production"]
    log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"]

# 測試
Config(environment="production", log_level="INFO")  # ✅
Config(environment="invalid", log_level="INFO")     # ❌

使用 Enum

from pydantic import BaseModel
from enum import Enum

class Status(str, Enum):
    PENDING = "pending"
    APPROVED = "approved"
    REJECTED = "rejected"

class OrderStatus(str, Enum):
    CREATED = "created"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

class Order(BaseModel):
    id: int
    status: OrderStatus = OrderStatus.CREATED

# 使用
order = Order(id=1, status="paid")  # 字串會轉成 Enum
print(order.status)                  # OrderStatus.PAID
print(order.status.value)            # paid

聯合型別

from pydantic import BaseModel
from typing import Union, List

class StringOrList(BaseModel):
    # 可以是字串或字串列表
    tags: Union[str, List[str]]

# 兩種都可以
StringOrList(tags="python")
StringOrList(tags=["python", "fastapi"])

# Python 3.10+ 語法
class StringOrList2(BaseModel):
    tags: str | list[str]

🔐 密碼驗證範例

from pydantic import BaseModel, Field, field_validator
import re

class PasswordPolicy(BaseModel):
    """密碼政策驗證"""
    password: str = Field(
        ...,
        min_length=8,
        max_length=128,
        description="密碼"
    )

    @field_validator('password')
    @classmethod
    def validate_password_strength(cls, v: str) -> str:
        errors = []

        if not re.search(r'[A-Z]', v):
            errors.append("需要至少一個大寫字母")

        if not re.search(r'[a-z]', v):
            errors.append("需要至少一個小寫字母")

        if not re.search(r'\d', v):
            errors.append("需要至少一個數字")

        if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v):
            errors.append("需要至少一個特殊字元")

        if errors:
            raise ValueError("; ".join(errors))

        return v

class UserRegistration(BaseModel):
    username: str = Field(..., min_length=3, max_length=30)
    email: str = Field(..., pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
    password: str = Field(..., min_length=8)
    confirm_password: str

    @field_validator('password')
    @classmethod
    def validate_password(cls, v: str) -> str:
        if not re.search(r'[A-Z]', v):
            raise ValueError("密碼需要至少一個大寫字母")
        if not re.search(r'[a-z]', v):
            raise ValueError("密碼需要至少一個小寫字母")
        if not re.search(r'\d', v):
            raise ValueError("密碼需要至少一個數字")
        return v

    @field_validator('confirm_password')
    @classmethod
    def passwords_match(cls, v: str, info) -> str:
        if 'password' in info.data and v != info.data['password']:
            raise ValueError("密碼不一致")
        return v

📝 實戰範例:API 請求驗證

from pydantic import BaseModel, Field, EmailStr, field_validator
from typing import Optional, List
from datetime import date
from enum import Enum

class Gender(str, Enum):
    MALE = "male"
    FEMALE = "female"
    OTHER = "other"

class Address(BaseModel):
    """地址"""
    city: str = Field(..., min_length=1, max_length=50)
    district: str = Field(..., min_length=1, max_length=50)
    street: str = Field(..., min_length=1, max_length=200)
    zip_code: str = Field(..., pattern=r"^\d{3,5}$")

class UserRegistrationRequest(BaseModel):
    """使用者註冊請求"""

    # 基本資訊
    username: str = Field(
        ...,
        min_length=3,
        max_length=30,
        pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$",
        description="使用者名稱,字母開頭,只能包含字母、數字、底線"
    )

    email: EmailStr = Field(
        ...,
        description="電子郵件"
    )

    password: str = Field(
        ...,
        min_length=8,
        max_length=128,
        description="密碼,至少 8 個字元"
    )

    # 個人資訊
    full_name: str = Field(
        ...,
        min_length=1,
        max_length=100,
        description="全名"
    )

    phone: Optional[str] = Field(
        None,
        pattern=r"^09\d{8}$",
        description="手機號碼"
    )

    birth_date: Optional[date] = Field(
        None,
        description="生日"
    )

    gender: Optional[Gender] = None

    # 地址
    address: Optional[Address] = None

    # 偏好設定
    receive_newsletter: bool = Field(
        default=False,
        description="是否接收電子報"
    )

    interests: List[str] = Field(
        default_factory=list,
        max_length=10,
        description="興趣標籤,最多 10 個"
    )

    @field_validator('birth_date')
    @classmethod
    def validate_birth_date(cls, v: Optional[date]) -> Optional[date]:
        if v is not None:
            today = date.today()
            age = today.year - v.year - ((today.month, today.day) < (v.month, v.day))
            if age < 13:
                raise ValueError("必須年滿 13 歲")
            if age > 120:
                raise ValueError("年齡不合理")
        return v

    @field_validator('interests')
    @classmethod
    def validate_interests(cls, v: List[str]) -> List[str]:
        # 去重並轉小寫
        return list(set(item.lower().strip() for item in v if item.strip()))

✅ 重點總結

驗證約束速查表

約束適用型別範例
gt, ge, lt, le數字Field(ge=0, le=100)
multiple_of數字Field(multiple_of=5)
min_length, max_length字串/列表Field(min_length=1)
pattern字串Field(pattern=r"^\d+$")
Literal任意Literal["a", "b"]
Enum任意class Status(str, Enum)

常用特殊型別

型別用途
EmailStrEmail 驗證
HttpUrlURL 驗證
SecretStr敏感資料
IPvAnyAddressIP 位址
UUID4UUID v4

🎤 面試這樣答

Q: Pydantic 如何驗證一個欄位必須是正數?

答案:

使用 Field 的 gt 或 ge 參數:

from pydantic import BaseModel, Field

class Product(BaseModel):
    price: float = Field(gt=0)      # 大於 0
    quantity: int = Field(ge=1)     # 大於等於 1

gt = greater than(大於) ge = greater than or equal(大於等於)


🤓 小測驗

  1. 如何限制字串只能是特定的幾個值?

  2. min_length 可以用在什麼型別?

  3. 如何驗證 email 格式?


上一篇: 02-1. Pydantic 基礎模型 下一篇: 02-3. 自訂驗證器


最後更新:2025-12-17

0%