توسعه بک‌اند با FastAPI فهرست مطالب
فصل ۳

Pydantic: اعتبارسنجی و طرح‌واره‌ها

دیگر به کلاینت اعتماد نمی‌کنیم. هر داده‌ای که وارد API می‌شود یا از آن خارج می‌شود را با یک مدل Pydantic توصیف می‌کنیم: چه فیلدهایی هست، کدام اجباری است، چه مقدارهایی مجاز است و کلاینت اجازه دیدن چه چیزی را دارد. تفاوت داده‌ای که ذخیره می‌کنیم، داده‌ای که می‌پذیریم و داده‌ای که برمی‌گردانیم را یاد می‌گیریم: الگوی Create و Update و Read که بقیه دوره روی آن بنا می‌شود.

در این فصل می‌خوانید
  1. BaseModel: dataclassی که بررسی می‌کند
  2. Field و Annotated: قاعده برای هر فیلد
  3. اجباری، پیش‌فرض، اختیاری
  4. مدل‌های تودرتو
  5. اعتبارسنج سفارشی
  6. سریال‌سازی و from_attributes
  7. پیش‌بینی کنید: چرا قبول شد، چرا رد شد؟
  8. مدل پاسخ: کنترل آنچه از API خارج می‌شود
  9. یک منبع، سه طرح‌واره
  10. پروژه: API آموزشگاه، نسخه دو
  11. خلاصه فصل

۱. BaseModel: dataclassی که بررسی می‌کند

در فصل اول dataclassها به هر چیزی که می‌گرفتند اعتماد می‌کردند. داده‌ای که از اینترنت می‌رسد قابل اعتماد نیست. یک مدل Pydantic:

from pydantic import BaseModel, ValidationError


class Course(BaseModel):
    id: int
    title: str
    price: int
    is_active: bool = True


def main() -> None:
    course = Course(id='7', title='FastAPI', price=4_800_000, is_active='yes')
    print(course)  # '7' became 7, 'yes' became True
    print(course.id + 1)

    data_from_json = {'id': 8, 'title': 'SQL', 'price': 2_900_000}
    print(Course.model_validate(data_from_json))
    print(Course.model_validate_json('{"id": 9, "title": "Git", "price": 3000000}'))

    try:
        Course(id='abc', title=None, price=1.5)
    except ValidationError as error:
        print(f'{error.error_count()} errors:')
        for item in error.errors():
            print('  ', item['loc'], '->', item['msg'])


if __name__ == '__main__':
    main()

تبدیل و خطا: نمونه 01_basemodel/app.py

id=7 title='FastAPI' price=4800000 is_active=True
3 errors:
   ('id',) -> Input should be a valid integer, unable to parse string as an integer
   ('title',) -> Input should be a valid string
   ('price',) -> Input should be a valid integer, got a number with a fractional part

همین فهرست خطاها، وقتی درون FastAPI رخ دهد، به شکل پاسخ ۴۲۲ به کلاینت می‌رسد.

۲. Field و Annotated: قاعده برای هر فیلد

from typing import Annotated

from pydantic import BaseModel, Field, ValidationError

Price = Annotated[int, Field(ge=0, le=1_000_000_000, description='Price in Toman')]
Title = Annotated[str, Field(min_length=3, max_length=200)]


class Course(BaseModel):
    title: Title
    code: str = Field(pattern=r'^[A-Z]{3}-\d{3}$', examples=['FST-101'])
    price: Price
    capacity: int = Field(gt=0, le=500)


def main() -> None:
    print(Course(title='FastAPI', code='FST-101', price=4_800_000, capacity=12))

    bad_courses = [
        {'title': 'AI', 'code': 'FST-101', 'price': 1, 'capacity': 1},
        {'title': 'FastAPI', 'code': 'fst101', 'price': 1, 'capacity': 1},
        {'title': 'FastAPI', 'code': 'FST-101', 'price': -5, 'capacity': 0},
    ]
    for data in bad_courses:
        try:
            Course(**data)
        except ValidationError as error:
            print([f'{e["loc"][0]}: {e["msg"]}' for e in error.errors()])


if __name__ == '__main__':
    main()

قاعده‌های فیلد و نوع‌های قابل استفاده دوباره: نمونه 02_field_constraints/app.py

قاعدهمعنی
min_length، max_lengthطول رشته
ge، gtبزرگ‌تر یا مساوی، بزرگ‌تر
le، ltکوچک‌تر یا مساوی، کوچک‌تر
patternعبارت باقاعده
description، examplesفقط برای مستند /docs

با Annotated که در فصل اول دیدیم، قاعده یک بار تعریف و همه‌جا استفاده می‌شود: Price = Annotated[int, Field(ge=0)].

۳. اجباری، پیش‌فرض، اختیاری

from pydantic import BaseModel, ValidationError


class CourseForm(BaseModel):
    title: str
    level: str = 'beginner'
    description: str | None = None
    teacher_note: str | None  # required, but may be null!


def main() -> None:
    form = CourseForm(title='FastAPI', teacher_note=None)
    print(form)
    print('sent by the client:', form.model_fields_set)

    try:
        CourseForm(title='FastAPI')
    except ValidationError as error:
        print(error.errors()[0]['loc'], error.errors()[0]['msg'])

    patch = CourseForm(title='FastAPI', teacher_note=None, description=None)
    print(patch.model_dump(exclude_unset=True))  # only what was sent


if __name__ == '__main__':
    main()

سه حالت متفاوت: نمونه 03_optional_and_defaults/app.py

دام رایج

str | None بدون مقدار پیش‌فرض هنوز اجباری است: کلاینت باید کلید را بفرستد، حتی اگر مقدارش null باشد. برای اختیاری شدن، = None لازم است.

model_fields_set می‌گوید کلاینت واقعاً کدام فیلدها را فرستاده است. PATCH به همین طریق «فرستاده نشده» را از «با null فرستاده شده» جدا می‌کند.

۴. مدل‌های تودرتو

JSON درخت است و مدل‌های Pydantic هم می‌توانند درخت باشند. هر سطح بررسی می‌شود و محل خطا کل مسیر را نشان می‌دهد:

class Instructor(BaseModel):
    id: int
    full_name: str


class ClassSession(BaseModel):
    number: int = Field(ge=1, le=16)
    topic: str


class Course(BaseModel):
    title: str
    instructor: Instructor
    tags: list[str] = []
    sessions: list[ClassSession] = []

مدل درون مدل: نمونه 04_nested_models/app.py

('instructor', 'full_name') Field required
('sessions', 1, 'number') Input should be less than or equal to 16

۵. اعتبارسنج سفارشی

قاعده‌هایی که Field نمی‌تواند بیان کند در یک تابع می‌آیند:

from typing import Self

from pydantic import BaseModel, EmailStr, ValidationError, field_validator, model_validator


class UserCreate(BaseModel):
    email: EmailStr
    full_name: str
    password: str

    @field_validator('full_name')
    @classmethod
    def clean_name(cls, value: str) -> str:
        value = ' '.join(value.split())  # '  sara   ahmadi ' -> 'sara ahmadi'
        if len(value) < 2:
            raise ValueError('name is too short')
        return value.title()

    @field_validator('password')
    @classmethod
    def strong_password(cls, value: str) -> str:
        if len(value) < 8 or value.isalpha() or value.isdigit():
            raise ValueError('at least 8 characters, with letters AND digits')
        return value


class PriceRange(BaseModel):
    min_price: int = 0
    max_price: int = 1_000_000_000

    @model_validator(mode='after')
    def check_order(self) -> Self:
        if self.min_price > self.max_price:
            raise ValueError('min_price cannot be greater than max_price')
        return self


def main() -> None:
    print(UserCreate(email='Sara@Example.com', full_name='  sara   ahmadi ', password='secret123'))

    for data in [
        {'email': 'not-an-email', 'full_name': 'S', 'password': 'short'},
        {'email': 'a@b.com', 'full_name': 'Ali', 'password': 'onlyletters'},
    ]:
        try:
            UserCreate(**data)
        except ValidationError as error:
            print([f'{e["loc"][0]}: {e["msg"]}' for e in error.errors()])

    try:
        PriceRange(min_price=500, max_price=100)
    except ValidationError as error:
        print(error.errors()[0]['msg'])


if __name__ == '__main__':
    main()

field_validator و model_validator: نمونه 05_custom_validators/app.py

۶. سریال‌سازی و from_attributes

from pydantic import BaseModel, ConfigDict, computed_field


class CourseRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    price: int
    capacity: int
    enrolled: int
    created_at: datetime

    @computed_field
    @property
    def seats_left(self) -> int:
        return self.capacity - self.enrolled


@dataclass
class CourseRow:
    """Pretend this came from a database."""

    id: int
    title: str
    price: int
    capacity: int
    enrolled: int
    created_at: datetime
    internal_note: str


def main() -> None:
    row = CourseRow(1, 'FastAPI', 4_800_000, 12, 10, datetime(2026, 9, 24, 18, 0), 'secret')

    course = CourseRead.model_validate(row)  # reads attributes, ignores internal_note
    print(course.model_dump())
    print(course.model_dump(include={'title', 'seats_left'}))
    print(course.model_dump_json(indent=2))


if __name__ == '__main__':
    main()

از مدل به دیکشنری و JSON: نمونه 06_serialization/app.py

۷. پیش‌بینی کنید: چرا قبول شد، چرا رد شد؟

این بخش تمرین کلاسی است. فایل 07_diagnose_validation/app.py دوازده ورودی دارد. پیش از اجرا برای هر کدام روی کاغذ بنویسید قبول می‌شود یا رد، و اگر رد می‌شود به خاطر کدام فیلد. بعد اجرا کنید و مقایسه کنید.

class Enrollment(BaseModel):
    student_id: int
    course_id: int = Field(gt=0)
    paid: bool = False
    amount: int = Field(default=0, ge=0)
    note: str | None = None
    strict_code: int | None = Field(default=None, strict=True)


CASES = [
    {'student_id': 1, 'course_id': 2},
    {'student_id': '1', 'course_id': '2'},
    {'student_id': 1.0, 'course_id': 2},
    {'student_id': 1.5, 'course_id': 2},
    {'student_id': 1, 'course_id': 0},
    {'student_id': 1, 'course_id': 2, 'paid': 'yes'},
    {'student_id': 1, 'course_id': 2, 'paid': 'maybe'},
    {'student_id': 1, 'course_id': 2, 'amount': '-1'},
    {'student_id': 1, 'course_id': 2, 'note': 42},
    {'student_id': 1, 'course_id': 2, 'strict_code': '7'},
    {'student_id': 1, 'course_id': 2, 'unknown_field': 'x'},
    {'course_id': 2},
]

دوازده ورودی برای پیش‌بینی: نمونه 07_diagnose_validation/app.py

چند پاسخ غافلگیرکننده

مورد ۳ (1.0) قبول می‌شود، چون عدد اعشاری بدون کسر بی‌خطر به عدد صحیح تبدیل می‌شود؛ مورد ۴ (1.5) رد می‌شود. مورد ۱۰ ('7') رد می‌شود چون آن فیلد strict=True است و تبدیل را خاموش کرده است. مورد ۱۱ با فیلد ناشناخته قبول می‌شود، چون Pydantic به‌طور پیش‌فرض فیلدهای اضافه را نادیده می‌گیرد؛ با extra='forbid' رد می‌شود.

۸. مدل پاسخ: کنترل آنچه از API خارج می‌شود

کاربر ذخیره‌شده یک درهم‌ساز رمز دارد که هرگز نباید برگردد. نوع بازگشتی endpoint یا response_model مدلی بدون آن فیلد است:

USERS = {
    1: {'id': 1, 'email': 'sara@example.com', 'full_name': 'Sara', 'password_hash': 'x9$2...'},
}


class UserRead(BaseModel):
    id: int
    email: str
    full_name: str


@app.get('/users/{user_id}', response_model=UserRead)
def get_user(user_id: int) -> dict:
    if user_id not in USERS:
        raise HTTPException(404, detail='User not found')
    return USERS[user_id]  # the hash is in here, but it will be filtered out


@app.get('/users', response_model=list[UserRead])
def list_users() -> list[dict]:
    return list(USERS.values())

پنهان کردن درهم‌ساز رمز: نمونه 08_response_model/app.py

FastAPI داده خروجی را با UserRead بررسی می‌کند و هر فیلدی را که آنجا تعریف نشده حذف می‌کند. مستند /docs هم دقیقاً همین شکل را نشان می‌دهد.

۹. یک منبع، سه طرح‌واره

داده ورودی و داده خروجی یک شکل ندارند:

طرح‌وارهپاسخ به این سؤال
CourseCreateکلاینت هنگام ساختن چه چیزی را می‌تواند تعیین کند؟ (بدون id، بدون تاریخ)
CourseUpdateکلاینت چه چیزی را می‌تواند تغییر دهد؟ (همه اختیاری)
CourseReadکلاینت چه چیزی را می‌تواند ببیند؟ (با id و تاریخ ساخت)
مدل پایگاه داده (فصل ۵)داده چگونه ذخیره می‌شود؟
class CourseBase(BaseModel):
    title: str = Field(min_length=3, max_length=200)
    price: int = Field(ge=0)
    capacity: int = Field(gt=0, le=500)


class CourseCreate(CourseBase):
    pass


class CourseUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=3, max_length=200)
    price: int | None = Field(default=None, ge=0)
    capacity: int | None = Field(default=None, gt=0, le=500)


class CourseRead(CourseBase):
    id: int
    created_at: datetime


COURSES: dict[int, CourseRead] = {}

Create و Update و Read: نمونه 09_create_update_read/app.py

چهار کلاس برای یک «دوره» ممکن است زیاد به نظر برسد، اما مخلوط کردن آنها همان راهی است که API‌ها از طریقش رمز لو می‌دهند یا به دانشجو اجازه می‌دهند role: admin بفرستد.

۱۰. پروژه: API آموزشگاه، نسخه دو

همه طرح‌واره‌های پروژه در یک فایل schemas.py جمع می‌شوند و هیچ endpointی دیگر دیکشنری خام نمی‌پذیرد یا برنمی‌گرداند.

Level = Literal['beginner', 'intermediate', 'advanced']
Role = Literal['admin', 'instructor', 'student']
Title = Annotated[str, Field(min_length=3, max_length=200)]
Price = Annotated[int, Field(ge=0, le=1_000_000_000, description='Price in Toman')]
Capacity = Annotated[int, Field(gt=0, le=500)]


# ---------- courses ----------
class CourseBase(BaseModel):
    title: Title
    description: str = Field(default='', max_length=2000)
    price: Price
    capacity: Capacity
    level: Level = 'beginner'

    @field_validator('title')
    @classmethod
    def clean_title(cls, value: str) -> str:
        return ' '.join(value.split())


class CourseCreate(CourseBase):
    instructor_id: int


class CourseUpdate(BaseModel):
    title: Title | None = None
    description: str | None = Field(default=None, max_length=2000)
    price: Price | None = None
    capacity: Capacity | None = None
    level: Level | None = None

    @model_validator(mode='after')
    def not_empty(self) -> Self:
        if not self.model_fields_set:
            raise ValueError('send at least one field to change')
        return self


class CourseRead(CourseBase):
    id: int
    instructor_id: int
    created_at: datetime

طرح‌واره‌های دوره: 10_project/schemas.py

در Bruno پوشه 10_project/bruno را اجرا کنید. درخواست «Create course - every field wrong» چهار خطا را در یک پاسخ نشان می‌دهد.

فایل‌های تمرینی این فصل

پوشه S03-pydantic-validation ده پوشه دارد. نمونه‌های ۰۱ تا ۰۷ اسکریپت‌اند و فقط python app.py لازم دارند. نمونه‌های ۰۸ و ۰۹ سرورند. پروژه با python main.py در 10_project اجرا می‌شود.

۱۱. خلاصه فصل

ابزاروظیفه آن
BaseModelتبدیل و بررسی داده
model_validate، model_validate_jsonساختن مدل از دیکشنری یا متن
Field(ge=..., max_length=...)قاعده برای یک فیلد
Annotated[int, Field(...)]قاعده قابل استفاده دوباره
= Noneفیلد اختیاری
model_fields_set، exclude_unsetتشخیص فیلدهای فرستاده‌شده
@field_validator، @model_validatorقاعده سفارشی
EmailStrبررسی ایمیل
from_attributes=Trueخواندن از شیء، نه فقط دیکشنری
@computed_fieldمقدار محاسبه‌شده در خروجی
نوع بازگشتی یا response_modelکنترل آنچه خارج می‌شود
Create / Update / Readسه طرح‌واره برای یک منبع

تمرین‌های این فصل

  1. در 01_basemodel یک Course از {"id": "7.0", "title": 12, "price": "4,800,000"} بسازید. پیش از اجرا نتیجه هر فیلد را پیش‌بینی کنید.
  2. به Course مقدار model_config = ConfigDict(extra='forbid') بدهید. برای فیلد ناشناخته چه تغییری می‌کند؟
  3. نوع Phone را برای شماره همراه ایرانی (09 و نه رقم) با pattern تعریف کنید.
  4. به UserCreate فیلد password_repeat اضافه کنید و با model_validator برابری دو رمز را بررسی کنید.
  5. برای هر پیش‌بینی نادرستتان در 07_diagnose_validation یک جمله توضیح بنویسید.
  6. در پروژه CourseDetail بسازید که مدرس را به شکل یک UserRead تودرتو برگرداند.
  7. @computed_field برای صندلی‌های خالی دوره در پروژه اضافه کنید.
  8. PUT /courses/{course_id} را با طرح‌واره CourseReplace بنویسید. چرا instructor_id حالت خاصی دارد؟
  9. تکلیف: برای API کتابخانه BookCreate، BookUpdate، BookRead و MemberCreate، MemberRead بنویسید: شابک ۱۰ یا ۱۳ رقمی، سال بین ۱۴۵۰ و امسال، ایمیل معتبر و یک model_validator که کتابی با تعداد صفحه کمتر از تعداد فصل را رد کند.
فصل قبل: قدم‌های اول FastAPI فصل بعد: تزریق وابستگی، ساختار، خطاها و تنظیمات