FastAPI

Pydantic v2 у FastAPI — схеми, валідація та серіалізація

Глибоке занурення у проектування схем даних у FastAPI за допомогою Pydantic v2. Розбір стратегії розділення моделей для введення, виведення та оновлення, наслідування моделей та порівняння з DTO в ASP.NET Core.

У сучасній веб-розробці валідація вхідних даних та серіалізація відповідей є критично важливими процесами. Вони безпосередньо впливають на безпеку, продуктивність та архітектурну чистоту додатку. У екосистемі FastAPI ці завдання повністю делеговані бібліотеці Pydantic — потужному інструменту для парсингу та валідації даних за допомогою стандартних підказок типів Python (Type Hints).

З виходом Pydantic v2 архітектура валідації була встановлена на новому рівні та переписана на мові Rust (pydantic-core), що дало прискорення процесів валідації та серіалізації від 5 до 17 разів. FastAPI, починаючи з версії 0.100.0, повністю інтегрував Pydantic v2, надаючи розробникам доступ до нових оптимізованих механізмів роботи зі схемами.

У цій статті ми детально розглянемо стратегії проектування моделей даних, розберемо наслідування схем для дотримання принципу DRY (Don't Repeat Yourself) без порушення API-контрактів, а також проведемо детальні паралелі з концепцією DTO (Data Transfer Objects), що використовується в ASP.NET Core.


Pydantic v2 як технологічний фундамент FastAPI

FastAPI та Pydantic працюють у тісній синергії. Коли запит надходить на сервер, FastAPI автоматично виконує такі кроки:

  1. Аналіз сигнатури ендпоінту: Фреймворк досліджує вказані в параметрах функції типи даних. Якщо тип параметра є підкласом pydantic.BaseModel, FastAPI очікує отримати тіло запиту у форматі JSON.
  2. Десеріалізація та валідація: Вхідний JSON-пакет конвертується у словник Python і передається у відповідну Pydantic-модель. Rust-рушій pydantic-core валідує структуру на відповідність типам та додатковим обмеженням (наприклад, довжині рядка, формату email).
  3. Обробка помилок: Якщо дані не пройшли валідацію, Pydantic генерує детальний опис невідповідностей. FastAPI перехоплює це виключення та автоматично повертає клієнту відповідь зі статусом 422 Unprocessable Entity у структурованому вигляді.
  4. Генерація OpenAPI-документації: Pydantic моделі автоматично конвертуються в об'єкти JSON Schema, які потім вбудовуються у загальну специфікацію OpenAPI (/docs або /redoc).

Завдяки використанню стандарту PEP 484 (Type Hints) розробнику не потрібно писати окремий код для парсингу чи генерації документації — декларативний опис типів слугує єдиним джерелом правди (Single Source of Truth).


Стратегія проектування моделей: Чому один клас для всього — антипатерн

Початківці часто припускаються серйозної архітектурної помилки: створюють одну-єдину модель Pydantic для відображення сутності на всіх етапах її життєвого циклу. Наприклад, оголошують єдиний клас User, який містить усе — від системного ідентифікатора (id) до хешу пароля (hashed_password).

Такий підхід призводить до трьох основних проблем:

Витік конфіденційних даних
Security Vulnerability
Якщо модель містить паролі, платіжні дані чи внутрішні системні прапорці, використання її для повернення даних клієнту (наприклад, у відповіді GET /users/{id}) призведе до випадкового витоку чутливої інформації.
Конфлікт обмежень валідації
Validation Mismatch
При створенні об'єкта (POST) поле id ще не існує (воно генерується базою даних), тому в моделі воно має бути опціональним (id: int | None = None). Проте при читанні об'єкта (GET) ідентифікатор є обов'язковим. Спроба об'єднати ці вимоги в одному класі послаблює валідацію.
Складність підтримки часткових оновлень (PATCH)
State Pollution
Для методу PATCH усі поля мають бути необов'язковими, щоб клієнт міг оновити лише одне поле (наприклад, тільки email). Якщо використовувати ту саму модель, що й для створення (POST), де поля username та email є обов'язковими, ви не зможете реалізувати коректний PATCH.

Розділення за ролями: Створення ієрархії DTO

Для забезпечення безпеки та гнучкості API необхідно розділяти схеми за їхнім призначенням у життєвому циклі запиту:

  1. Input-моделі (Request Schemas) — описують дані, які надсилає клієнт. Наприклад, UserCreate для реєстрації (містить пароль у відкритому вигляді) або UserUpdate для оновлення профілю.
  2. Output-моделі (Response Schemas) — описують структуру відповіді сервера. Наприклад, UserRead (містить id, created_at, але не містить паролів чи токенів).
  3. Internal-моделі (Internal/Database Models) — використовуються всередині додатку для збереження у БД або передачі між шарами сервісів (містить hashed_password).

Порівняння архітектурних концепцій: ASP.NET Core DTOs ↔ FastAPI Pydantic Schemas

Концепція розділення моделей на вхідні та вихідні є загальноприйнятим індустріальним стандартом. У C# / ASP.NET Core для цього використовується патерн DTO (Data Transfer Object). Розглянемо концептуальні та синтаксичні відмінності між підходами в ASP.NET Core та FastAPI.

У ASP.NET Core ви створюєте окремі класи DTO та накладаєте валідаційні обмеження за допомогою Data Annotations (атрибутів). Мапінг між сутностями бази даних (Entity) та DTO зазвичай виконується за допомогою бібліотек на кшталт AutoMapper або вручну.

У FastAPI Pydantic-моделі одночасно виконують роль DTO, валідатора та серіалізатора. Мапінг із сутностями БД (наприклад, SQLAlchemy) відбувається автоматично через режим сумісності атрибутів (from_attributes=True).

Порівняємо опис вхідної моделі для створення користувача в обох екосистемах:

from pydantic import BaseModel, EmailStr, Field

class UserCreate(BaseModel):
    # EmailStr автоматично валідує формат пошти
    email: EmailStr
    # Field накладає обмеження на довжину пароля
    password: str = Field(min_length=8, max_length=100)
    username: str = Field(min_length=3, max_length=50)

Встановлення залежностей та запуск коду

Для роботи з Pydantic v2 та FastAPI вам потрібні відповідні пакети. Нижче наведено інструкції для їх встановлення:

# Встановлення FastAPI та Pydantic v2 разом з веб-сервером uvicorn та валідатором email
pip install fastapi[standard] pydantic[email]

Наслідування моделей: Дотримання принципу DRY

Хоча створення багатьох моделей захищає контракти API, це може призвести до дублювання коду (оскільки email та username потрібні як при створенні, так і при читанні чи оновленні). Щоб уникнути копіювання, Pydantic підтримує звичайне ООП-наслідування класів.

Рекомендований шаблон проектування передбачає створення базового класу UserBase, який містить спільні для всіх представлень поля. Конкретні класи наслідують його, додаючи специфічні поля чи конфігурацію:

          [ BaseModel ]
                │
                ▼
          [ UserBase ]  (email, username)
           ╱        ╲
          ▼          ▼
   [ UserCreate ]  [ UserRead ]
   (password)      (id, is_active, created_at)

Розглянемо практичну реалізацію цієї структури та її інтеграцію в роутер FastAPI:

main.py
# main.py
from datetime import datetime
from pydantic import BaseModel, EmailStr, ConfigDict, Field
from fastapi import FastAPI, status

app = FastAPI(title="Pydantic Inheritance Demo")

# 1. Базова модель: спільні атрибути для входу та виходу
class UserBase(BaseModel):
    email: EmailStr
    username: str = Field(..., min_length=3, max_length=50)

# 2. Модель для створення користувача: додаємо пароль
class UserCreate(UserBase):
    password: str = Field(..., min_length=8, description="Пароль у відкритому вигляді")

# 3. Модель для відповіді: додаємо ID та статус, але приховуємо пароль
class UserRead(UserBase):
    id: int
    is_active: bool = True
    created_at: datetime

    # Конфігурація Pydantic v2 для роботи з ORM (наприклад, SQLAlchemy, Tortoise)
    # Замінює orm_mode = True з Pydantic v1
    model_config = ConfigDict(from_attributes=True)

# Симуляція бази даних
database_users: list[dict] = []
user_id_counter = 1

@app.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(user_in: UserCreate):
    global user_id_counter

    # Хешування пароля (в реальному проекті використовуйте bcrypt/argon2)
    hashed_password = f"secret_hash_{user_in.password}"

    # Створюємо словник для збереження в БД
    user_db = {
        "id": user_id_counter,
        "email": user_in.email,
        "username": user_in.username,
        "hashed_password": hashed_password,
        "is_active": True,
        "created_at": datetime.now()
    }

    database_users.append(user_db)
    user_id_counter += 1

    # FastAPI автоматично відфільтрує hashed_password і поверне дані
    # відповідно до структури моделі UserRead (оскільки вказано response_model)
    return user_db

Інструкція запуску та тестування ендпоінту

Для запуску веб-сервера з кодом асинхронного додатку використовується Uvicorn. Скопіюйте відповідну команду запуску для вашої операційної системи:

uvicorn main:app --reload

Нижче наведено вивід терміналу, що підтверджує успішний запуск сервера:

Вивід Uvicorn сервера
INFO: Will watch for changes in these directories: ['/Users/arakviel/Work/kostyl.dev']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [12345] using StatReload
INFO: Started server process [12346]
INFO: Waiting for application startup.
INFO: Application startup complete.

Після запуску ви можете перевірити роботу валідації вхідних та вихідних схем за допомогою консольних cURL-запитів.

Сценарій 1: Успішне створення користувача (Валідні дані)

Надішлемо коректні дані, що відповідають усім вимогам UserCreate. Скопіюйте команду відповідно до вашої системи:

curl -X POST http://127.0.0.1:8000/users \
  -H "Content-Type: application/json" \
  -d '{"email": "john@example.com", "password": "supersecurepassword123", "username": "johndoe"}'
Консольний вивід відповіді
HTTP/1.1 201 Created
Content-Type: application/json
{"email":"john@example.com","username":"johndoe","id":1,"is_active":true,"created_at":"2026-07-01T10:10:00.123456"}

Сценарій 2: Помилка валідації (Некоректний email та занадто короткий пароль)

Надішлемо некоректний email та пароль менше 8 символів. Pydantic перехопить помилку ще до запуску бізнес-логіки обробника:

curl -X POST http://127.0.0.1:8000/users \
  -H "Content-Type: application/json" \
  -d '{"email": "invalid-email-format", "password": "short", "username": "jo"}'
Консольний вивід відповіді помилки
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"detail":[{"type":"value_error","loc":["body","email"],"msg":"value is not a valid email address: An email address must have an @-sign.","input":"invalid-email-format"},{"type":"string_too_short","loc":["body","password"],"msg":"String should have at least 8 characters","input":"short","ctx":{"min_length":8}},{"type":"string_too_short","loc":["body","username"],"msg":"String should have at least 3 characters","input":"jo","ctx":{"min_length":3}}]}

Частина II: Часткові оновлення та вкладені структури

У реальних API-інтерфейсах обмін даними рідко обмежується простими пласькими структурами. Клієнти часто надсилають запити на оновлення окремих полів ресурсу або запитують складні ієрархічні об'єкти (наприклад, замовлення разом із переліком товарів). Pydantic v2 надає витончені інструменти для вирішення цих завдань за допомогою параметрів експорту моделей, рекурсивної валідації та строкових посилань.


Сучасний синтаксис: Optional чи | None?

У попередніх версіях Python та Pydantic v1 для оголошення опціональних полів імпортували Optional з модуля typing:

main.py
# Застарілий синтаксис (PEP 484)
from typing import Optional
email: Optional[str] = None

Починаючи з Python 3.10+ (PEP 604), було введено оператор об'єднання типів за допомогою вертикальної риски |. Тепер замість Optional[T] рекомендується використовувати сучасний та чистіший синтаксис T | None:

main.py
# Сучасний синтаксис (PEP 604)
email: str | None = None
FastAPI та Pydantic v2 повністю підтримують PEP 604. Використання str | None усуває необхідність імпорту з модуля typing, полегшує читання коду та робить його лаконічнішим. Optional залишається в мові для зворотної сумісності, але у новому коді його використовувати не варто.

Часткові оновлення (PATCH) та робота з Optional полями

В архітектурі REST для оновлення ресурсу використовуються два методи:

  • PUT — повне заміщення ресурсу. Клієнт зобов'язаний передати всі обов'язкові поля. Якщо якесь поле не передане, воно перезаписується значенням за замовчуванням або скидається.
  • PATCH — часткове оновлення. Клієнт передає лише ті поля, які він хоче змінити. Інші поля сутності в базі даних залишаються незмінними.

Для реалізації методу PATCH у Pydantic створюється спеціальна схема (наприклад, UserUpdate), де всі поля оголошуються як опціональні та мають значення за замовчуванням None.

Проте на практиці виникає проблема: як розрізнити два випадки?

  1. Клієнт не передав поле в JSON-пакеті (значення має залишитися незмінним у БД).
  2. Клієнт явно передав поле зі значенням null (наприклад, "bio": null), щоб стерти існуюче значення в БД.

У Pydantic v1 для цього використовувався метод .dict(exclude_unset=True). У Pydantic v2 цей метод застарів, і замість нього використовується новий метод .model_dump() разом із наступними конфігураційними параметрами:

exclude_unset=True
bool
Включає у вихідний словник лише ті поля, які були явно присутні у вхідному JSON-пакеті від клієнта. Це головний інструмент для реалізації PATCH-запитів.
exclude_defaults=True
bool
Виключає поля, значення яких дорівнюють їхнім значенням за замовчуванням (незалежно від того, були вони передані клієнтом чи ні).
exclude_none=True
bool
Видаляє зі словника всі поля, значення яких дорівнюють None. Для PATCH-запитів цей параметр зазвичай не використовується, оскільки він не дозволяє клієнту явно встановити поле у null в базі даних.

Проблема невалідності null: як дозволити опускання поля, але заборонити передачу null

Розглянемо випадок, коли поле username у базі даних є обов'язковим (NOT NULL), але при PATCH-запиті клієнт має право його опустити (не передавати). Якщо ми опишемо модель так:

main.py
class UserUpdate(BaseModel):
    username: str | None = None

При надсиланні запиту {} (порожній словник) поле username прийме дефолтне значення None. Завдяки exclude_unset=True це поле не потрапить у словник оновлення, і база даних не зміниться.

Але що станеться, якщо клієнт надішле запит:

data.json
{
    "username": null
}

Оскільки тип поля оголошено як str | None, Pydantic пропустить цей запит як валідний, запише значення None у об'єкт, а після виклику model_dump(exclude_unset=True) ми отримаємо {"username": None}. Спроба записати None (NULL) у колонку бази даних username призведе до помилки IntegrityError на рівні СКБД.

Для вирішення цієї проблеми у Pydantic v2 є два підходи:

1. Неузгодженість типів із дефолтом (Type-Default Mismatch)

Ми оголошуємо тип поля як str (без | None), але призначаємо йому дефолтне значення None за допомогою класу Field:

main.py
class UserUpdate(BaseModel):
    username: str = Field(default=None)

Як це працює? Pydantic за замовчуванням не валідує дефолтні значення при ініціалізації моделі без параметрів. Якщо клієнт опускає "username", поле приймає значення None. Проте, якщо клієнт явно передає "username": null, Pydantic валідує це вхідне значення проти вказаного типу str. Оскільки None не є рядком, валідація впаде з помилкою ValidationError: Input should be a valid string. Недолік: Статичні аналізатори кодів (наприклад, mypy чи pyright) покажуть попередження, оскільки типу str присвоюється значення None.

2. Використання валідатора поля (@field_validator) — рекомендований і типізовано безпечний шлях

Ми зберігаємо тип str | None, але явно забороняємо передачу None на рівні валідатора:

main.py
from pydantic import BaseModel, Field, field_validator

class UserUpdate(BaseModel):
    username: str | None = Field(default=None, min_length=3, max_length=50)

    @field_validator("username")
    @classmethod
    def prevent_null(cls, value: str | None) -> str | None:
        # Якщо поле присутнє в запиті, воно не повинно бути None
        if value is None:
            raise ValueError("Поле username не може бути null (скинути значення не можна)")
        return value

Нижче наведено робочий приклад PATCH-ендпоінту з вирішенням цієї проблеми:

patch_demo.py
# patch_demo.py
from pydantic import BaseModel, EmailStr, Field, field_validator
from fastapi import FastAPI, HTTPException, status

app = FastAPI()

# Симуляція бази даних в пам'яті
db_user = {
    "id": 1,
    "email": "old_email@example.com",
    "username": "old_username",
    "bio": "Стара біографія"
}

class UserUpdate(BaseModel):
    email: EmailStr | None = None
    username: str | None = Field(None, min_length=3, max_length=50)
    bio: str | None = Field(None, max_length=500) # Біографію скинути в null ДОЗВОЛЕНО

    @field_validator("username")
    @classmethod
    def prevent_null(cls, value: str | None) -> str | None:
        if value is None:
            raise ValueError("Поле username не може бути null")
        return value

@app.patch("/users/{user_id}", status_code=status.HTTP_200_OK)
async def update_user(user_id: int, user_in: UserUpdate):
    if user_id != db_user["id"]:
        raise HTTPException(status_code=404, detail="Користувача не знайдено")

    # Отримуємо тільки явно передані клієнтом поля
    update_data = user_in.model_dump(exclude_unset=True)

    # Оновлюємо нашу симульовану базу даних
    for key, value in update_data.items():
        db_user[key] = value

    return db_user

Запуск та тестування PATCH-ендпоінту

Запустіть додаток через Uvicorn:

uvicorn patch_demo:app --reload

Сценарій 1: Часткове оновлення профілю та скидання біографії в null

Клієнт оновлює email та затирає біографію, інші поля не передаються:

curl -X PATCH http://127.0.0.1:8000/users/1 \
  -H "Content-Type: application/json" \
  -d '{"email": "new_email@example.com", "bio": null}'
Консольний вивід відповіді PATCH
HTTP/1.1 200 OK
Content-Type: application/json
{"id":1,"email":"new_email@example.com","username":"old_username","bio":null}

Сценарій 2: Відхилення запиту при спробі скинути обов'язкове поле в null

Спробуємо скинути обов'язкове ім'я користувача, надіславши "username": null:

curl -X PATCH http://127.0.0.1:8000/users/1 \
  -H "Content-Type: application/json" \
  -d '{"username": null}'
Консольний вивід помилки валідації
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"detail":[{"type":"value_error","loc":["body","username"],"msg":"Value error, Поле username не може бути null","input":null}]}

Вкладені моделі та відношення (Nested Models)

Реальні сутності часто мають зв'язки «один-до-багатьох» чи «багато-до-багатьох». У Pydantic зв'язки описуються шляхом використання інших моделей як типів полів.

Коли Pydantic валідує вкладену модель, він виконує рекурсивний аналіз:

  1. Створюється екземпляр батьківської моделі.
  2. Для кожного вкладеного поля викликається відповідний валідатор дочірньої моделі.
  3. Якщо на будь-якому рівні вкладеності виникає помилка, Pydantic формує точний шлях до неї (наприклад, loc: ["body", "items", 2, "price"]), що дозволяє клієнту чітко зрозуміть, які саме дані є некоректними.

Розглянемо схему замовлення з вкладеними товарами:

nested_models.py
# nested_models.py
from pydantic import BaseModel, Field

class OrderItem(BaseModel):
    product_id: int
    quantity: int = Field(..., gt=0, description="Кількість має бути більшою за нуль")
    price: float = Field(..., gt=0.0)

class OrderCreate(BaseModel):
    customer_id: int
    # Використовуємо список інших Pydantic моделей
    items: list[OrderItem] = Field(..., min_length=1, description="Замовлення має містити хоча б один товар")

# Приклад вхідного валідного JSON:
# {
#   "customer_id": 42,
#   "items": [
#     {"product_id": 101, "quantity": 2, "price": 15.50},
#     {"product_id": 102, "quantity": 1, "price": 9.99}
#   ]
# }

Глибокий розбір строкових посилань (Forward References) та model_rebuild()

Python є динамічною мовою, що інтерпретується послідовно зверху вниз. Це створює певні обмеження при моделюванні зв'язків між сутностями.

Суть проблеми: Випереджальні посилання (Forward References)

Уявімо класичну схему блогу, де автор (AuthorRead) містить список своїх постів (PostRead), а пост (PostRead) містить посилання на свого автора:

main.py
class AuthorRead(BaseModel):
    id: int
    name: str
    posts: list[PostRead]  # ❌ NameError: name 'PostRead' is not defined

Оскільки інтерпретатор Python ще не зустрів визначення класу PostRead, використання його як типу викликає помилку NameError. Ми не можемо просто поміняти класи місцями, тому що тоді PostRead спробує послатися на невідомий йому AuthorRead.

Як це вирішується в Python та Pydantic?

Для вирішення цієї проблеми використовуються строкові посилання (Forward References). Ми вказуємо назву класу у вигляді звичайного рядка:

main.py
posts: list['PostRead'] = []

У цьому випадку Python під час створення класу сприймає 'PostRead' як звичайний текстовий рядок (str) і не намагається розв'язати його як тип, завдяки чому помилка NameError не виникає.

Починаючи з Python 3.7, ви можете додати імпорт from __future__ import annotations на самому початку файлу. Ця інструкція змушує інтерпретатор автоматично перетворювати всі анотації типів на текстові рядки на етапі парсингу файлу. Це знижує навантаження на систему при імпорті модулів та автоматично усуває більшість NameError випереджальних посилань.

Проте Pydantic не може валідувати дані за допомогою звичайного текстового рядка 'PostRead' — йому потрібен доступ до реальної структури класу. Тому в Pydantic v2 існує двоетапний процес:

  1. Реєстрація ForwardRef: Ми можемо явно створити об'єкт ForwardRef або передати строковий тип.
  2. Виклик model_rebuild(): Коли всі задіяні класи вже визначені, ми викликаємо метод model_rebuild() на батьківських класах. Це змушує Pydantic пройтися по всіх строкових типах у моделях, знайти реальні вонименні класи в глобальному просторі імен модуля (globals()) та побудувати для них кінцеві валідаційні схеми.

Повна реалізація взаємозв'язку між моделями без циклічних помилок:

relationships.py
# relationships.py
from __future__ import annotations
from pydantic import BaseModel

class AuthorRead(BaseModel):
    id: int
    name: str
    # Вказуємо тип як рядок, оскільки PostRead ще не оголошено нижче
    posts: list[PostRead] = []

class PostRead(BaseModel):
    id: int
    title: str
    content: str
    author_id: int
    # Використовуємо реальний клас AuthorRead (він уже оголошений вище)
    author: AuthorRead

# Після того, як обидва класи завантажені в пам'ять,
# ми змушуємо Pydantic зв'язати строкові посилання з реальними класами:
AuthorRead.model_rebuild()
PostRead.model_rebuild()

Завдяки цьому ми отримуємо чисту двосторонню серіалізацію об'єктів без помилок на етапі імпорту модулів.


Частина III: Кастомні типи даних та кастомізація JSON-схеми

Сучасні веб-додатки потребують валідації специфічних для бізнесу форматів даних (номерів телефонів, ІПН, спеціальних кодів). Можливостей стандартних типів (рядок, число) часто недостатньо. Pydantic v2 пропонує потужний механізм створення власних типів через поєднання стандартних інструментів та розширеної валідації Annotated, а також надає повний контроль над генерацією JSON-схем для інтерактивної документації Swagger.


Кастомні типи даних за допомогою Annotated та валідаторів

У Python 3.9 було представлено PEP 593 (Annotated Metadata), який дозволяє додавати довільні метадані до типів. Pydantic v2 зробив Annotated першокласним стандартом для опису додаткових обмежень та правил валідації.

Замість створення складних підкласів Pydantic дозволяє конструювати типи «на льоту», комбінуючи базові типи з об'єктами валідації:

  • BeforeValidator — викликається до внутрішньої валідації Pydantic. Отримує «сирі» дані (наприклад, рядок з HTTP-запиту) і зазвичай використовується для очищення, приведення типів або форматування (препроцесинг).
  • AfterValidator — викликається після успішної внутрішньої валідації Pydantic. Отримує вже типізований об'єкт і використовується для додаткової логічної перевірки бізнес-правил (постпроцесинг).

Створення кастомного типу для номерів телефонів (формат E.164)

Розглянемо практичний приклад створення власного типу UkrainePhoneNumber. Цей тип має автоматично очищати номер від зайвих символів (пробілів, дужок) та перевіряти, чи належить він українському оператору зв'язку:

custom_types.py
# custom_types.py
import re
from typing import Annotated
from pydantic import BaseModel, Field, AfterValidator, BeforeValidator

def clean_phone_number(v: str) -> str:
    """Видаляє всі символи крім цифр та знаку плюс (BeforeValidator)."""
    if not isinstance(v, str):
        raise ValueError("Номер телефону має бути рядком")
    cleaned = re.sub(r"[ \-\(\)]", "", v)

    if cleaned.startswith("0") and len(cleaned) == 10:
        cleaned = f"+38{cleaned}"
    elif cleaned.startswith("380") and len(cleaned) == 12:
        cleaned = f"+{cleaned}"

    return cleaned

def validate_ukraine_operator(v: str) -> str:
    """Перевіряє відповідність формату E.164 та коду України (AfterValidator)."""
    pattern = r"^\+380\d{9}$"
    if not re.match(pattern, v):
        raise ValueError("Номер телефону має бути валідним українським номером у форматі +380XXXXXXXXX")
    return v

# Оголошуємо кастомний тип за допомогою Annotated
UkrainePhoneNumber = Annotated[
    str,
    BeforeValidator(clean_phone_number),
    AfterValidator(validate_ukraine_operator)
]

class CallbackRequest(BaseModel):
    client_name: str = Field(..., min_length=2, max_length=100)
    phone: UkrainePhoneNumber

Кастомізація JSON-схеми та Swagger UI

FastAPI автоматично генерує документацію Swagger UI, аналізуючи Pydantic-моделі та перетворюючи їх на специфікацію OpenAPI, яка базується на стандартах JSON Schema.

Pydantic v2 надає розробнику повний контроль над тим, як саме поля та моделі відображатимуться в JSON-схемі, за допомогою двох механізмів:

  1. Параметра json_schema_extra в об'єкті Field (для окремих полів).
  2. Параметра json_schema_extra в конфігурації модели ConfigDict (для всієї моделі).

1. Кастомізація на рівні окремих полів

Через Field ми можемо передати додаткові метадані, такі як приклади (examples), які відображатимуться в Swagger UI як плейсхолдери для запиту:

main.py
from pydantic import BaseModel, Field

class ProductCreate(BaseModel):
    name: str = Field(
        ...,
        min_length=3,
        description="Назва товару для відображення в каталозі",
        json_schema_extra={"examples": ["Ноутбук Lenovo ThinkPad"]}
    )
    price: float = Field(
        ...,
        gt=0.0,
        json_schema_extra={"examples": [1249.99]}
    )

2. Кастомізація на рівні всієї моделі

Якщо необхідно надати цілісний приклад JSON-документа, зручніше налаштувати приклад на рівні моделі через model_config:

schema_customization.py
# schema_customization.py
from pydantic import BaseModel, ConfigDict, Field
from fastapi import FastAPI

app = FastAPI()

class ProjectCreate(BaseModel):
    title: str = Field(..., min_length=3, max_length=100)
    description: str | None = Field(None, max_length=1000)
    budget: float = Field(..., ge=0.0)

    model_config = ConfigDict(
        json_schema_extra={
            "example": {
                "title": "Розробка корпоративного порталу",
                "description": "Створення внутрішнього веб-ресурсу для співробітників компанії",
                "budget": 15000.00
            }
        }
    )

@app.post("/projects")
async def create_project(project: ProjectCreate):
    return {"status": "ok", "project": project}

Запуск та тестування ендпоінту із кастомною JSON-схемою

Запустіть веб-сайт за допомогою Uvicorn:

uvicorn schema_customization:app --reload

Тепер надішлемо POST запит із приведеними вище параметрами проекту:

curl -X POST http://127.0.0.1:8000/projects \
  -H "Content-Type: application/json" \
  -d '{"title": "Розробка корпоративного порталу", "description": "Створення внутрішнього веб-ресурсу для співробітників компанії", "budget": 15000.00}'
Консольний вивід відповіді POST
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"ok","project":{"title":"Розробка корпоративного порталу","description":"Створення внутрішнього веб-ресурсу для співробітників компанії","budget":15000.0}}

Частина IV: Обмеження значень та завантаження файлів

Для побудови стійких інтерфейсів необхідно обмежувати можливі значення полів фіксованими списками (наприклад, статуси задач, рівні доступу) та вміти працювати з бінарними даними — завантаженням файлів (зображень, документів) через HTTP. FastAPI та Pydantic надають для цього декларативні структури Enum/Literal та оновлений клас UploadFile.


Обмеження значень: Enum та Literal

Коли поле моделі має приймати значення виключно з чітко визначеного набору, використовуються два інструменти:

  1. enum.Enum (або enum.StrEnum у Python 3.11+) — системний перелік значень.
  2. typing.Literal (PEP 586) — дозволяє явно вказати конкретні допустимі літеральні значення.

Використання StrEnum (Python 3.11+)

У класичному Python Enum значення елементів можуть бути будь-котрими, а серіалізація вимагає звернення до .value. У розробці API на Python 3.11+ рекомендується використовувати StrEnum. Клас StrEnum наслідує рядок, тому його елементи автоматично серіалізуються Pydantic як звичайні рядки:

main.py
from enum import StrEnum

class TaskStatus(StrEnum):
    TODO = "todo"
    IN_PROGRESS = "in_progress"
    DONE = "done"

Використання Literal (PEP 586) — детальний розбір

Конструкція Literal з модуля typing дозволяє вказувати типи, значення яких мають точно відповідати одному із наданих текстових або числових літералів.

Чим Literal відрізняється від Enum?

  • Enum є окремим класом у Python, елементи якого представляють об'єкти типу Enum. Його краще використовувати для глобальних доменних переліків (наприклад, статуси завдань, рівні доступу користувачів), які використовуються у багатьох файлах проекту.
  • Literal визначає тип на рівні окремої змінної/поля і не потребує оголошення додаткових класів. Він ідеально підходить для фіксації конкретних службових значень (наприклад, версія API "v1"/"v2", тип задачі "task"/"bug", або напрямок сортування "asc"/"desc").

Коли Pydantic валідує поле з типом Literal["task", "bug"]:

  • Він перевіряє точне співпадіння вхідного рядка з одним із вказаних значень.
  • Якщо надіслати "invalid", Pydantic згенерує помилку ValidationError із чітким повідомленням: Input should be 'task' or 'bug'.
  • У схемі OpenAPI для цього поля автоматично прописується обмеження enum: ["task", "bug"].
enum_literal_demo.py
# enum_literal_demo.py
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, Field
from fastapi import FastAPI, status

app = FastAPI()

class TaskPriority(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class TaskCreate(BaseModel):
    title: str = Field(..., min_length=3, max_length=100)
    priority: TaskPriority = TaskPriority.MEDIUM
    # Поле типу Literal — приймає тільки "task" або "bug"
    task_type: Literal["task", "bug"] = "task"

@app.post("/tasks", status_code=status.HTTP_201_CREATED)
async def create_task(task: TaskCreate):
    return task

Запуск та тестування ендпоінту з Enum/Literal

Запустимо веб-сайт з додатком Uvicorn:

uvicorn enum_literal_demo:app --reload

Тепер виконаємо перевірки вхідної валідації.

Сценарій 1: Успішний запит (Значення з переліків)

Надішлемо коректні значення priority та task_type:

curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Написати тести", "priority": "high", "task_type": "bug"}'
Консольний вивід успіху
HTTP/1.1 201 Created
Content-Type: application/json
{"title":"Написати тести","priority":"high","task_type":"bug"}

Сценарій 2: Помилка валідації (Передача недопустимого значення в Literal)

Надішлемо "task_type": "story", якого немає у списку Literal:

curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Написати тести", "task_type": "story"}'
Консольний вивід помилки Literal
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"detail":[{"type":"literal_error","loc":["body","task_type"],"msg":"Input should be 'task' or 'bug'","input":"story","ctx":{"expected":"'task' or 'bug'"}}]}

Завантаження файлів (File Upload)

На відміну від передачі текстових даних у форматі application/json, завантаження файлів на сервер здійснюється за допомогою кодування multipart/form-data.

FastAPI пропонує два способи отримання файлів від клієнта:

  1. bytes — завантаження всього файлу в оперативну пам'ять сервера:
    Застереження: Підходить лише для маленьких файлів. Завантаження файлу розміром 1 ГБ призведе до споживання 1 ГБ оперативної пам'яті процесом Python, що може викликати збій сервера через переповнення пам'яті (Out of Memory).
  2. UploadFile — асинхронна обгортка над тимчасовим файлом на диску. Вона використовує буферизований файл (SpooledTemporaryFile), який зберігається в пам'яті до певного ліміту розміру, а при перевищенні автоматично записується на диск. Цей підхід є максимально пам'яттєво-ефективним.

Ключові властивості та методи UploadFile

  • filename — оригінальне ім'я файлу (наприклад, photo.jpg).
  • content_type — MIME-тип файлу (наприклад, image/jpeg).
  • file — об'єкт файлового дескриптора Python (можна передавати безпосередньо в інші функції читання).
  • await read(size) — асинхронне зчитування вказаної кількості байт з файлу.
  • await write(data) — асинхронний запис бінарних даних у файл.
  • await seek(offset) — переміщення вказівника у файлі.
  • await close() — закриття файлу та очищення тимчасової пам'яті/файлу на диску.
Методи read(), write(), seek() та close() є асинхронними (корутинами), оскільки під капотом FastAPI використовує пул потоків для взаємодії з дисковою файловою системою, щоб не блокувати головний Event Loop вашого додатку під час тривалого запису великих файлів.

Порівняння архітектурних концепцій: ASP.NET Core IFormFile ↔ FastAPI UploadFile

Розробники, які переходять з екосистеми .NET Core, звикли працювати з інтерфейсом IFormFile для отримання файлів у контролерах. Концептуально UploadFile у FastAPI виконує абсолютно ідентичну роль, оптимізуючи роботу з пам'яттю та надаючи асинхронний інтерфейс.

Порівняємо основні властивості та методи обох об'єктів:

Характеристика / ЗадачаASP.NET Core (IFormFile)FastAPI (UploadFile)
Оригінальне ім'я файлуfile.FileNamefile.filename
MIME-тип файлуfile.ContentTypefile.content_type
Розмір файлуfile.Length (у байтах)Немає прямої властивості (потрібно зчитати через seek/read або перевірити заголовок Content-Length)
Отримання потоку данихfile.OpenReadStream()Доступ до файлу безпосередньо через file.file
Асинхронне збереженняawait file.CopyToAsync(stream)Запис зчитаних байтів через await file.read() та стандартні асинхронні бібліотеки роботи з диском (наприклад, aiofiles)

Порівняємо код ендпоінтів для збереження завантаженого аватара користувача на диск. Приклад для FastAPI є повним: він також містить GET-маршрут для відображення HTML-форми завантаження:

# avatar_demo.py
import os
import aiofiles
from fastapi import FastAPI, File, UploadFile, status
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.get("/", response_class=HTMLResponse)
async def main():
    # Повертаємо просту HTML-сторінку з формою для завантаження файлу
    content = """
    <!DOCTYPE html>
    <html>
    <head>
        <title>Завантаження аватара</title>
        <style>
            body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f3f4f6; margin: 0; }
            .card { background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); text-align: center; }
            input[type="file"] { margin: 1rem 0; display: block; }
            button { background: #3b82f6; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; }
            button:hover { background: #2563eb; }
        </style>
    </head>
    <body>
        <div class="card">
            <h2>Завантажити аватар</h2>
            <!-- Важливо: enctype="multipart/form-data" -->
            <form action="/upload-avatar" enctype="multipart/form-data" method="post">
                <input name="file" type="file" accept="image/*" required>
                <button type="submit">Надіслати</button>
            </form>
        </div>
    </body>
    </html>
    """
    return HTMLResponse(content=content)

@app.post("/upload-avatar", status_code=status.HTTP_201_CREATED)
async def upload_avatar(file: UploadFile = File(...)):
    # Створюємо директорію для збереження аватарок, якщо її немає
    os.makedirs("static/avatars", exist_ok=True)
    destination_path = f"static/avatars/{file.filename}"

    # Асинхронно відкриваємо файл для запису бінарних даних (wb)
    async with aiofiles.open(destination_path, "wb") as out_file:
        while content := await file.read(1024 * 1024):  # Читаємо порціями по 1 МБ
            await out_file.write(content)

    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "status": "success"
    }
Зверніть увагу: для асинхронної роботи з файловою системою в Python використовується бібліотека aiofiles, оскільки стандартна вбудована функція Python open() є блокуючою (синхронною). Бібліотека aiofiles делегує дискові операції пулу потоків, зберігаючи Event Loop вільним від блокувань.

Запуск та тестування ендпоінту завантаження файлів

Запустимо веб-сайт з додатком Uvicorn:

uvicorn avatar_demo:app --reload

Тепер ви можете або відкрити браузер за адресою http://127.0.0.1:8000/ та скористатися графічним інтерфейсом завантаження файлу, або виконати cURL-запит з використанням прапорця -F (Multipart Form data) для завантаження файлу безпосередньо з консолі:

curl -X POST http://127.0.0.1:8000/upload-avatar \
  -F "file=@avatar.png"
Консольний вивід відповіді завантаження
HTTP/1.1 201 Created
Content-Type: application/json
{"filename":"avatar.png","content_type":"image/png","status":"success"}

Частина V: Практичні завдання та інтеграція в TaskForge

Для закріплення матеріалу розберемо практичні вправи різного рівня складності: від проектування моделей блогу та побудови універсальних generic-схем до розробки самостійного мікропроекту валідації платежів та рефакторингу TaskForge.


Практичні вправи

Вправа 1: Проектування схем для CRUD блогу

Завдання: Спроектувати ієрархію Pydantic-моделей для сутностей Author (Автор), Post (Пост) та Comment (Коментар). Моделі мають запобігати дублюванню коду (DRY) та підтримувати такі вимоги:

  • При створенні коментаря обов'язково передавати post_id.
  • При виведенні поста (PostRead) повертати список коментарів (list[CommentRead]) та повну інформацію про автора (AuthorRead).
blog_schemas.py
# blog_schemas.py
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field

# --- СХЕМИ АВТОРА ---
class AuthorBase(BaseModel):
    name: str = Field(..., min_length=2, max_length=100)
    email: EmailStr

class AuthorCreate(AuthorBase):
    pass

class AuthorRead(AuthorBase):
    id: int
    class Config:
        from_attributes = True

# --- СХЕМИ КОМЕНТАРЯ ---
class CommentBase(BaseModel):
    content: str = Field(..., min_length=1, max_length=500)

class CommentCreate(CommentBase):
    post_id: int

class CommentRead(CommentBase):
    id: int
    created_at: datetime
    class Config:
        from_attributes = True

# --- СХЕМИ ПОСТА ---
class PostBase(BaseModel):
    title: str = Field(..., min_length=5, max_length=200)
    content: str = Field(..., min_length=10)

class PostCreate(PostBase):
    author_id: int

class PostRead(PostBase):
    id: int
    author: AuthorRead
    comments: list[CommentRead] = []
    created_at: datetime
    class Config:
        from_attributes = True

Вправа 2: Часткове оновлення статті з бізнес-валідацією

Завдання: Написати схему PostUpdate, де всі поля є опціональними. Додати валідатор, який перевіряє, що якщо поле title або content було передано, воно не повинно бути порожнім (не містити лише пробіли).

post_update.py
# post_update.py
from pydantic import BaseModel, Field, field_validator

class PostUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=5, max_length=200)
    content: str | None = Field(default=None, min_length=10)

    @field_validator("title", "content")
    @classmethod
    def check_not_empty(cls, value: str | None) -> str | None:
        if value is not None and not value.strip():
            raise ValueError("Поле не може складатися лише з пробілів")
        return value

Вправа 3: Побудова універсального Generic-серіалізатора для пагінації

Завдання: Створити універсальну модель відповіді PaginatedResponse[T]. Вона повинна огортати список об'єктів будь-якого типу та повертати службові дані про сторінки, кількість елементів тощо.

У професійній розробці опис схем пагінації для кожної сутності окремо призводить до дублювання коду. Для створення універсальної схеми використовується концепція Generics (узагальнених типів) з модуля typing:

pagination.py
# pagination.py
from typing import Generic, TypeVar
from pydantic import BaseModel

# Оголошуємо параметр типу T
T = TypeVar("T")

class PaginatedResponse(BaseModel, Generic[T]):
    items: list[T]
    total: int
    page: int
    size: int
    pages: int

# Приклад використання у FastAPI:
# @app.get("/users", response_model=PaginatedResponse[UserRead])
# async def get_users():
#     ...

Самостійний проект: Валідація транзакцій платіжного шлюзу

Розробимо завершений асинхронний веб-додаток, який виконує роль платіжного шлюзу, перевіряючи складні вхідні запити на оплату.

Функціонал проекту:

  1. Кастомні типи:
    • CardNumber — перевірка номера карти за алгоритмом Луна (Luhn Algorithm) та приведення до чистого рядка цифр.
    • ExpiryDate — перевірка формату MM/YY та підтвердження того, що термін дії картки ще не закінчився.
  2. Вкладена структура: PaymentRequest містить інформацію про транзакцію, дані картки та вкладену адресу виставлення рахунку (BillingAddress).

Створимо та запустимо цей додаток:

payment_gateway.py
# payment_gateway.py
import re
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, Field, AfterValidator, BeforeValidator, ConfigDict, field_validator
from fastapi import FastAPI, status, HTTPException
from fastapi.responses import HTMLResponse

app = FastAPI(title="Payment Gateway Validator")

# --- КАСТОМНІ ВАЛІДАТОРИ ---

def clean_card_number(v: str) -> str:
    if not isinstance(v, str):
        raise ValueError("Номер карти має бути рядком")
    # Видаляємо пробіли та дефіси
    return re.sub(r"[\s-]", "", v)

def validate_luhn(v: str) -> str:
    if not re.match(r"^\d{16}$", v):
        raise ValueError("Номер карти має складатися з 16 цифр")

    # Алгоритм Луна
    digits = [int(d) for d in v]
    checksum = 0
    for idx, digit in enumerate(reversed(digits)):
        if idx % 2 == 1:
            digit *= 2
            if digit > 9:
                digit -= 9
        checksum += digit

    if checksum % 10 != 0:
        raise ValueError("Невалідний номер карти (перевірка контрольної суми Луна провалена)")
    return v

def validate_expiry(v: str) -> str:
    if not re.match(r"^(0[1-9]|1[0-2])\/\d{2}$", v):
        raise ValueError("Термін дії має бути у форматі MM/YY")

    month, year = map(int, v.split("/"))
    current_year = int(str(datetime.now().year)[2:])
    current_month = datetime.now().month

    if year < current_year or (year == current_year and month < current_month):
        raise ValueError("Термін дії картки закінчився")
    return v

# --- ОГОЛОШЕННЯ ТИПІВ ---
CardNumber = Annotated[str, BeforeValidator(clean_card_number), AfterValidator(validate_luhn)]
ExpiryDate = Annotated[str, AfterValidator(validate_expiry)]

# --- МОДЕЛІ ДАНИХ ---

class BillingAddress(BaseModel):
    country: str = Field(..., min_length=2, max_length=2)  # Код країни ISO (напр. UA)
    city: str = Field(..., min_length=2, max_length=100)
    zip_code: str = Field(..., min_length=3, max_length=10)

class PaymentRequest(BaseModel):
    amount: float = Field(..., gt=0.0, description="Сума транзакції")
    currency: Literal["UAH", "USD", "EUR"]
    card_number: CardNumber
    card_expiry: ExpiryDate
    cvv: str = Field(..., pattern=r"^\d{3}$", description="Тризначний код CVV")
    billing_address: BillingAddress

    model_config = ConfigDict(
        json_schema_extra={
            "example": {
                "amount": 250.50,
                "currency": "UAH",
                "card_number": "4321-4321-4321-4321", # Необхідно вказати реальний номер Луна для тестів
                "card_expiry": "12/28",
                "cvv": "123",
                "billing_address": {
                    "country": "UA",
                    "city": "Kyiv",
                    "zip_code": "01001"
                }
            }
        }
    )

# --- МАРШРУТИ ---

@app.get("/", response_class=HTMLResponse)
async def index():
    content = """
    <!DOCTYPE html>
    <html>
    <head>
        <title>Payment Test</title>
        <style>
            body { font-family: sans-serif; background-color: #f3f4f6; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
            .card { background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 400px; }
            .form-group { margin-bottom: 1rem; }
            label { display: block; font-weight: bold; margin-bottom: 0.5rem; }
            input, select { width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 6px; box-sizing: border-box; }
            button { background: #10b981; color: white; border: none; padding: 0.75rem; width: 100%; border-radius: 6px; font-weight: bold; cursor: pointer; }
            button:hover { background: #059669; }
        </style>
    </head>
    <body>
        <div class="card">
            <h2>Оплата карткою</h2>
            <form action="/pay" method="post" id="payForm">
                <div class="form-group">
                    <label>Сума та Валюта</label>
                    <div style="display: flex; gap: 0.5rem;">
                        <input name="amount" type="number" step="0.01" value="100.00" required>
                        <select name="currency">
                            <option value="UAH">UAH</option>
                            <option value="USD">USD</option>
                            <option value="EUR">EUR</option>
                        </select>
                    </div>
                </div>
                <div class="form-group">
                    <label>Номер карти</label>
                    <input name="card_number" type="text" placeholder="4321-4321-4321-4321" required>
                </div>
                <div class="form-group">
                    <label>Термін дії та CVV</label>
                    <div style="display: flex; gap: 0.5rem;">
                        <input name="card_expiry" type="text" placeholder="MM/YY" required>
                        <input name="cvv" type="password" maxlength="3" placeholder="CVV" required>
                    </div>
                </div>
                <div class="form-group">
                    <label>Країна (ISO) та Місто</label>
                    <div style="display: flex; gap: 0.5rem;">
                        <input name="country" type="text" placeholder="UA" value="UA" required>
                        <input name="city" type="text" placeholder="Kyiv" required>
                    </div>
                </div>
                <div class="form-group">
                    <label>Поштовий індекс</label>
                    <input name="zip_code" type="text" placeholder="01001" required>
                </div>
                <button type="submit">Сплатити</button>
            </form>
        </div>
        <script>
            document.getElementById('payForm').addEventListener('submit', async (e) => {
                e.preventDefault();
                const formData = new FormData(e.target);
                const payload = {
                    amount: parseFloat(formData.get('amount')),
                    currency: formData.get('currency'),
                    card_number: formData.get('card_number'),
                    card_expiry: formData.get('card_expiry'),
                    cvv: formData.get('cvv'),
                    billing_address: {
                        country: formData.get('country'),
                        city: formData.get('city'),
                        zip_code: formData.get('zip_code')
                    }
                };

                const res = await fetch('/pay', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(payload)
                });
                const data = await res.json();
                alert(JSON.stringify(data, null, 2));
            });
        </script>
    </body>
    </html>
    """
    return HTMLResponse(content=content)

@app.post("/pay", status_code=status.HTTP_200_OK)
async def process_payment(payload: PaymentRequest):
    # Оскільки вся валідація пройшла успішно на рівні Pydantic,
    # ми впевнені у валідності номерів карт, термінів та форматів даних.
    return {
        "transaction_id": "tx_9876543210",
        "status": "success",
        "processed_at": datetime.now().isoformat(),
        "amount": payload.amount,
        "currency": payload.currency
    }

Запуск та тестування платіжного шлюзу

Запустимо веб-сайт з Uvicorn:

uvicorn payment_gateway:app --reload

Тепер ви можете відправити запит за допомогою cURL. Ми надішлемо карту, яка провалює перевірку Луна (некоректна контрольна сума):

curl -X POST http://127.0.0.1:8000/pay \
  -H "Content-Type: application/json" \
  -d '{"amount": 150.0, "currency": "USD", "card_number": "4321-4321-4321-4322", "card_expiry": "12/29", "cvv": "999", "billing_address": {"country": "UA", "city": "Lviv", "zip_code": "79000"}}'
Консольний вивід помилки карти
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"detail":[{"type":"value_error","loc":["body","card_number"],"msg":"Value error, Невалідний номер карти (перевірка контрольної суми Луна провалена)","input":"4321432143214322"}]}

Практика в TaskForge: Рефакторинг та захист роутерів

Тепер перенесемо отримані знання в наш навчальний проект TaskForge. Ваша задача — замінити нетипізовану роботу зі словниками (dict) у контролерах на валідацію за допомогою схем Pydantic v2.

Крок 1: Створення файлів схем

Створіть директорію schemas всередині проекту та додайте такі описи.

Для роботи зі статусами та пріоритетами задач додайте файли переліків:

app/schemas/enums.py
# app/schemas/enums.py
from enum import StrEnum

class TaskStatus(StrEnum):
    TODO = "todo"
    IN_PROGRESS = "in_progress"
    DONE = "done"

class TaskPriority(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

Схема проекту (app/schemas/project.py):

app/schemas/project.py
# app/schemas/project.py
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict

class ProjectBase(BaseModel):
    name: str = Field(..., min_length=3, max_length=100)
    description: str | None = Field(default=None, max_length=1000)

class ProjectCreate(ProjectBase):
    pass

class ProjectUpdate(ProjectBase):
    name: str | None = Field(default=None, min_length=3, max_length=100)

class ProjectRead(ProjectBase):
    id: int
    created_at: datetime

    model_config = ConfigDict(from_attributes=True)

Схема завдання (app/schemas/task.py):

app/schemas/task.py
# app/schemas/task.py
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
from app.schemas.enums import TaskStatus, TaskPriority

class TaskBase(BaseModel):
    title: str = Field(..., min_length=3, max_length=150)
    description: str | None = Field(default=None, max_length=2000)
    status: TaskStatus = TaskStatus.TODO
    priority: TaskPriority = TaskPriority.MEDIUM

class TaskCreate(TaskBase):
    project_id: int

class TaskUpdate(TaskBase):
    title: str | None = Field(default=None, min_length=3, max_length=150)
    status: TaskStatus | None = None
    priority: TaskPriority | None = None

class TaskRead(TaskBase):
    id: int
    project_id: int
    created_at: datetime

    model_config = ConfigDict(from_attributes=True)

Крок 2: Інтеграція в роутери

Оновіть ваші роутери FastAPI, використовуючи створені схеми як параметри запитів та для серіалізації відповідей:

app/routers/tasks.py
# app/routers/tasks.py
from fastapi import APIRouter, status, HTTPException
from app.schemas.task import TaskCreate, TaskRead, TaskUpdate

router = APIRouter(prefix="/tasks", tags=["Tasks"])

# Симуляція БД
db_tasks = []

@router.post("", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
async def create_task(task_in: TaskCreate):
    # Логіка збереження
    new_task = {
        "id": len(db_tasks) + 1,
        **task_in.model_dump(),
        "created_at": "2026-07-01T10:15:00Z"
    }
    db_tasks.append(new_task)
    return new_task

Завдяки вказівці response_model=TaskRead FastAPI автоматично відфільтрує будь-які внутрішні поля та надасть клієнту відповідь, що гарантовано відповідає інтерфейсному контракту.

Збережіть ваш прогрес у Git:

terminal
git add .
git commit -m "feat: add Pydantic schemas for request/response validation"
Copyright © 2026