FastAPI

Глибокий Typing та Pydantic v2 — від анотацій до валідації

Дослідження статичної типізації в сучасній екосистемі Python, пристрій Pydantic v2 та перехід від інспекції типів до автоматичної валідації даних.

Глибокий Typing та Pydantic v2 — від анотацій до валідації

Вступ: Парадигма Type-Driven Development у Python

Протягом тривалого часу Python сприймався виключно як мова з динамічною типізацією, де панував принцип «качиної типізації» (duck typing): "якщо це ходить як качка і крякає як качка, то це, мабуть, качка". Цей підхід забезпечував колосальну гнучкість та швидкість розробки на початкових етапах. Проте з ростом масштабів кодових баз, появою великих корпоративних систем та переходом до мікросервісної архітектури, динамічна типізація виявила свої критичні слабкості: відсутність автодоповнення в IDE, неможливість надійного статичного аналізу коду та виникнення прихованих runtime-помилок типу AttributeError чи TypeError у найменш очікувані моменти.

Для розробників, які переходять в екосистему Python із C# (ASP.NET Core), концепція динамічної типізації спочатку може здатися кроком назад. У C# типи є першокласними громадянами, які контролюються компілятором на етапі збирання проєкту (compile-time). Проте сучасний Python (починаючи з версії 3.5 і особливо в 3.10–3.12) пропонує потужний гібридний підхід: статичний аналіз типів (через mypy/pyright) у поєднанні з валідацією даних під час виконання (runtime) за допомогою Pydantic.

Це породило нову парадигму — Type-Driven Development (TDD у контексті типів). Замість того, щоб сприймати типи лише як документацію, сучасні Python-фреймворки, такі як FastAPI, використовують анотації типів як єдине джерело правди (Single Source of Truth) для:

  1. Валідації вхідних та вихідних даних (Request/Response validation).
  2. Генерації документації OpenAPI (Swagger/ReDoc).
  3. Dependency Injection (впровадження залежностей).

Без розуміння того, як працює сучасна система типізації Python, неможливо осягнути «магію» FastAPI. Тому наш шлях до вебфреймворків починається не з роутингу чи HTTP-запитів, а з фундаментальних інструментів типізації та бібліотеки Pydantic v2.


Частина I: Глибоке занурення в Type Hints

Система типізації в Python розвивалася поетапно. Важливо розуміти, що сам інтерпретатор Python (CPython) ігнорує анотації типів під час виконання коду — для нього вони залишаються метаданими, доступними через атрибут __annotations__. Всю роботу з перевірки коректності типів виконують статичні аналізатори (лінтери), такі як mypy, pyright або вбудовані інструменти IDE (PyCharm, VS Code).

Еволюція системи типізації: від PEP 484 до PEP 695

Давайте простежимо еволюцію анотацій на прикладі визначення типу для функції, яка приймає ідентифікатор користувача (який може бути як цілим числом, так і рядком) та повертає список його ролей або значення None, якщо користувача не знайдено.

from typing import Union, List, Optional

# PEP 484 вимагав використання спеціальних контейнерів з модуля typing
def get_user_roles(user_id: Union[int, str]) -> Optional[List[str]]:
    # Логіка отримання ролей
    pass
Чому це важливо для ASP.NET розробника: У C# аналогом Union[int, str] (або int | str) є використання oneof (у gRPC) або кастомних структур на кшталт OneOf<T0, T1>. В Python підтримка union-типів є нативною на рівні синтаксису типізації, що робить моделювання гнучких API значно простішим.

Базові правила та синтаксичні конструкції

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

1. Анотація змінних та констант

Анотація змінної визначається через двокрапку після її імені перед ініціалізацією:

main.py
# Анотація локальної змінної
db_port: int = 5432

# Анотація без ініціалізації (змінна існує лише для лінтера, доки їй не присвоєно значення)
connection_string: str

# Константні значення анотуються за допомогою Final (PEP 591)
from typing import Final
MAX_CONNECTIONS: Final[int] = 100
# Спроба змінити MAX_CONNECTIONS призведе до помилки статичного аналізатора (але не runtime)

2. Анотація функцій та методів

У функціях анотуються як параметри, так і значення, що повертається. Особливу увагу варто приділити методам класів, де перший параметр (self або cls) зазвичай не анотується, оскільки його тип виводиться автоматично:

main.py
class DatabaseConnector:
    def __init__(self, dsn: str) -> None:
        self.dsn: str = dsn  # Анотація атрибута класу

    def connect(self) -> bool:
        # self не потребує анотування
        return True

    @classmethod
    def from_env(cls, env_var: str) -> "DatabaseConnector":
        # Використання рядка-літерала "DatabaseConnector" потрібне,
        # якщо клас ще не повністю визначений у цьому місці файлу (Forward Reference)
        import os
        return cls(dsn=os.getenv(env_var, ""))

Принцип роботи під капотом: Runtime-ігнорування та Type Erasure

Фундаментальне правило Python полягає в тому, що анотації типів не впливають на виконання програми. Інтерпретатор Python повністю ігнорує їх під час генерації байт-коду. Це явище схоже на Type Erasure в Java або TypeScript, де типи існують лише на етапі розробки/транспіляції.

Спробуємо виконати наступний код:

main.py
def process_age(age: int) -> str:
    return f"Вік: {age * 2}"

# Передаємо рядок замість очікуваного int
print(process_age("25"))

Код виконається без runtime-помилок типізації, але виведе "Вік: 2525" через особливості перевантаження оператора * для рядків у Python. Статичний аналізатор (наприклад, mypy) на етапі перевірки підсвітить це як помилку: error: Argument 1 to "process_age" has incompatible type "str"; expected "int".

Метадані __annotations__ та get_type_hints()

Хоча інтерпретатор ігнорує типи для перевірок, він зберігає їх у спеціальному словнику __annotations__ об'єкта (класу чи функції). Це дозволяє бібліотекам на кшталт Pydantic читати ці метадані під час виконання.

Ніколи не читайте __annotations__ напряму в продуктивному коді! Завжди використовуйте typing.get_type_hints().

Справа у двох важливих аспектах:

  1. Forward References (Вперед-посилання): Якщо тип анотований класом, який визначений нижче у файлі, __annotations__ поверне рядок (наприклад, "DatabaseConnector"), тоді як get_type_hints() автоматично резолвить його в реальний об'єкт класу, якщо він став доступним.
  2. PEP 563 (Postponed Evaluation of Annotations): Якщо у файлі увімкнено відкладене обчислення (from __future__ import annotations), всі анотації зберігаються в __annotations__ як сирі рядки для оптимізації імпортів та пам'яті. get_type_hints() бере на себе завдання з их парсингу в контексті модуля.
main.py
from __future__ import annotations
from typing import get_type_hints

class User:
    manager: User  # Без future-імпорту це викликало б NameError, бо User ще не визначений

# Пряме читання поверне рядок:
print(User.__annotations__["manager"])  # Виведе: 'User'

# Використання хелпера поверне посилання на клас:
print(get_type_hints(User)["manager"])  # Виведе: <class '__main__.User'>

Номінальна проти Структурної типізації (Nominal vs Structural Typing)

Для C# розробника концепція типізації тісно пов'язана з класичним ООП: клас A сумісний з класом B лише якщо вони мають спільного предка або реалізують один інтерфейс. Це номінальна типізація (Nominal Typing).

Python за замовчуванням теж підтримує номінальну типізацію при наслідуванні класів. Проте, завдяки своїм динамічним кореням, він має першокласну підтримку структурної типізації (Structural Typing або Static Duck Typing), яка реалізована через typing.Protocol (PEP 544).

class Animal:
    def speak(self) -> str:
        raise NotImplementedError

class Dog(Animal):
    def speak(self) -> str:
        return "Woof"

def make_animal_speak(animal: Animal) -> str:
    return animal.speak()

make_animal_speak(Dog())  # Працює, бо Dog є Animal (номінально)
Protocol в Python є єквівалентом інтерфейсів у C# (або TypeScript). Головна відмінність — класу в Python не потрібно явно писати implements Interface. Якщо сигнатури методів та атрибутів збігаються, статичний аналізатор вважає тип сумісним. Це дозволяє створювати надзвичайно слабкопов'язані системи.

Вбудовані колекції та спеціальні типи

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

Чому квадратні дужки [ ] замість кутових < >?Розробники на C# звикли до запису generic-типів через кутові дужки, наприклад List<int> або Dictionary<string, string>. В Python для цього використовуються квадратні дужки — list[int] та dict[str, str].Це зумовлено особливостями синтаксичного аналізатора (parser) Python: використання < та > призвело б до граматичних конфліктів із математичними операторами порівняння «менше» та «більше». Крім того, квадратні дужки в Python традиційно відповідають за операцію доступу за індексом (subscription).Під капотом цей механізм працює так: коли ми пишемо list[int], інтерпретатор викликає спеціальний магічний метод класу __class_getitem__ (введений у PEP 560). Метод list.__class_getitem__(int) створює та повертає спеціальний об'єкт типу types.GenericAlias. Цей об'єкт зберігає інформацію про базовий клас та типи його параметрів у runtime, що дозволяє статичним аналізаторам та бібліотекам на кшталт Pydantic зчитувати параметризовані типи.

1. Типізація колекцій: фіксований vs динамічний розмір

При типізації колекцій важливо розуміти, як лінтери сприймають довжину структури:

  • Списки та множини (list[T], set[T]) — це динамічні колекції однорідних елементів. Ми анотуємо лише тип елементів, які в них містяться.
  • Словники (dict[K, V]) — анотуються двома типами: тип ключа та тип значення.
  • Кортежі (tuple) — мають дві форми анотування:
    1. Фіксована довжина та структура: tuple[int, str, bool] описує кортеж строго з трьох елементів вказаних типів (еквівалент кортежу в C# (int, string, bool)).
    2. Довільна довжина: tuple[int, ...] описує кортеж довільної довжини, де всі елементи є цілими числами (еквівалент ReadOnlySpan<int> або IEnumerable<int> у C#).
main.py
# Динамічні колекції
user_ids: list[int] = [1, 2, 3]
unique_emails: set[str] = {"user@test.com"}
headers: dict[str, str] = {"Content-Type": "application/json"}

# Фіксований кортеж (координати: x, y, назва точки)
point: tuple[int, int, str] = (10, 20, "A")

# Кортеж довільної довжини (лише числа)
numbers: tuple[int, ...] = (1, 2, 3, 4, 5)

2. Any проти object: границя безпеки

В системі типізації Python є два типи, які виглядають схоже, але несуть діаметрально протилежний зміст для статичного аналізу:

  • Any (з модуля typing) — вимикає перевірку типів для змінної. Лінтер дозволяє викликати на об'єкті з типом Any будь-які методи та звертатися до будь-каких полів. Це єквівалент ключового слова dynamic у C#.
  • object — є базовим класом для всіх об'єктів у Python. Якщо змінна анотована як object, лінтер забороняє викликати на ній будь-які методи (окрім базових для object на кшталт __str__), доки ви явно не приведете тип за допомогою звуження. Це єквівалент типу object у C#.
main.py
from typing import Any

def process_dynamic(data: Any) -> None:
    # Лінтер мовчить, але в runtime може впасти AttributeError, якщо методу немає
    data.send_request()

def process_safe(data: object) -> None:
    # Помилка лінтера: "object" has no attribute "send_request"
    # data.send_request()

    # Правильний безпечний шлях (звуження типу):
    if isinstance(data, DatabaseConnector):
        data.connect() # Тепер лінтер знає, що це DatabaseConnector

3. Типізація зворотних викликів (Callable)

Для типізації функцій, які передаються як аргументи (callback-функції, обробники подій, middleware), використовується typing.Callable.

Синтаксис виглядає так: Callable[[ТипАргументу1, ТипАргументу2], ТипПовернення].

main.py
from typing import Callable

# Функція приймає рядок і число, повертає булеве значення
type ValidatorFunc = Callable[[str, int], bool]

def validate_input(value: str, min_len: int, validator: ValidatorFunc) -> bool:
    return validator(value, min_len)

# Приклад реалізації валідатора
def length_validator(text: str, length: int) -> bool:
    return len(text) >= length

validate_input("hello", 3, length_validator)  # OK

Параметричний поліморфізм: Generics в Python

Як і в C# (List<T>), в Python ви можете створювати узагальнені класи та функції, які працюють з різними типами даних, зберігаючи при цьому строгість типізації.

1. Еволюція Generics: TypeVar проти синтаксису Python 3.12 (PEP 695)

До версії Python 3.12 створення узагальненого класу вимагало явного оголошення змінної типу за допомогою TypeVar та наслідування від Generic[T].

from typing import TypeVar, Generic

# Створюємо змінну типу
T = TypeVar("T")

class Repository(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def add(self, item: T) -> None:
        self._items.append(item)

    def get_all(self) -> list[T]:
        return self._items
Обмеження типу (Type Constraints):Ви можете обмежити множину допустимих типів.
  • У старому синтаксисі: T = TypeVar("T", int, str) або T = TypeVar("T", bound=Animal).
  • У новому синтаксисі 3.12+: class Repository[T: Animal] (еквівалент where T : Animal у C#).

2. Типізація метаданих функцій: ParamSpec (PEP 612)

При написанні декораторів часто виникає проблема: як зберегти сигнатуру аргументів декорованої функції? Якщо використати звичайний TypeVar, ми втратимо інформацію про назви та типи параметрів. Для цього введено ParamSpec (Parameter Specification).

Він дозволяє захопити сигнатуру параметрів однієї функції та перенести її на іншу (зазвичай на обгортку всередині декоратора).

main.py
import time
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def log_execution_time(func: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start_time = time.perf_counter()
        result = func(*args, **kwargs)
        end_time = time.perf_counter()
        print(f"Функція {func.__name__} виконалася за {end_time - start_time:.4f} сек")
        return result
    return wrapper

@log_execution_time
def calculate_sum(a: int, b: int) -> int:
    return a + b

# Статичний аналізатор тепер знає точні типи аргументів:
calculate_sum(10, 20)  # OK
# calculate_sum("10", 20)  # Помилка типізації!

Анотування метаданих типу: Annotated[T, metadata] (PEP 593)

Однією з найважливіших інновацій системи типізації Python, яка стала фундаментом для сучасного дизайну FastAPI та Pydantic v2, є typing.Annotated.

Концептуально Annotated[T, metadata] дозволяє розширити будь-який існуючий тип T довільними runtime-метаданими, які ігноруються статичним лінтером (лінтер бачить лише тип T), но можуть бути зчитані фреймворком під час виконання.

Чому це важливо: порівняння з атрибутами в C#

У C# для додавання метаданих до параметрів методів або властивостей класів використовуються атрибути:

Program.cs
public IActionResult GetUser([FromRoute][Range(1, 100)] int id) { ... }

В Python довгий час не було нативного способу зробити це елегантно. Annotated вирішує цю проблему:

main.py
from typing import Annotated
from pydantic import Field

# Записуємо еквівалент для FastAPI / Pydantic:
UserId = Annotated[int, Field(ge=1, le=100)]

Синтаксис та застосування

Метаданих у Annotated може бути кілька. Вони передаються як наступні аргументи після основного типу:

main.py
from typing import Annotated

# Тип: ціле число, яке додатково містить опис та правила валідації
type DatabasePort = Annotated[int, "Порт підключення до БД", {"min_value": 1024, "max_value": 65535}]

Звуження типів та перевантаження: TypeGuard, TypeIs та overload

При роботі зі складними типами даних часто виникає потреба підказати лінтеру, що після виконання певної перевірки тип змінної змінився (звузився).

1. TypeGuard (PEP 647) vs TypeIs (PEP 742)

TypeGuard дозволяє створювати функції-предикати, які повідомляють лінтеру, що аргумент належить до певного типу, якщо функція повертає True. Проте TypeGuard мав недолік: він звужував тип лише для позитивного сценарію (True), але не вмів автоматично відкидати цей тип для негативного сценарію (False). Тому в Python 3.13 (але доступний через typing_extensions і раніше) з'явився TypeIs.

from typing import TypeGuard

def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    # Якщо повертає True, лінтер вважає, що це list[str]
    return all(isinstance(x, str) for x in val)

items: list[object] = ["a", "b", 1]

if is_string_list(items):
    # Тут items має тип list[str]
    print(items[0].upper())
else:
    # Тут items залишається list[object] (лінтер не знає, що там немає рядків)
    pass

2. Перевантаження функцій через @overload (PEP 484)

У C# перевантаження методів є нативним механізмом: ви можете написати кілька методів з однаковим ім'ям, але різними параметрами. В Python ви не можете визначити дві функції з однаковим ім'ям у тій самій області видимості — остання просто перезапише попередню.

Для типізації сценаріїв, коли функція може приймати різні комбінації типів аргументів та повертати відповідні типи, використовується декоратор @overload. Самі декоратори слугують лише для лінтера, а реальна runtime-функція (без декоратора) має однаково обробляти всі варіанти.

main.py
from typing import overload

# 1. Специфікація для випадку, коли на вхід іде int
@overload
def double(value: int) -> int: ...

# 2. Специфікація для випадку, коли на вхід іде str
@overload
def double(value: str) -> str: ...

# 3. Реальна runtime-реалізація (без @overload)
def double(value: int | str) -> int | str:
    if isinstance(value, int):
        return value * 2
    elif isinstance(value, str):
        return value + value
    raise TypeError("Непідтримуваний тип")

# Статичний аналізатор тепер знає точний тип повернення:
result_int = double(5)      # Лінтер знає, що result_int: int
result_str = double("abc")  # Лінтер знає, що result_str: str

Налаштування статичного аналізу: mypy у CI/CD

Вся потужність системи типізації розкривається лише за умови використання лінтерів. Стандартом де-факто є інструмент mypy.

Для великих проєктів критично важливо налаштувати mypy у строгому режимі (strict), який забороняє неявний тип Any, вимагає анотацій для всіх функцій та перевіряє безпеку роботи з None (аналог nullable reference types у C#).

Конфігурація mypy зазвичай зберігається у файлі pyproject.toml:

config.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
show_error_codes = true
pretty = true

# Якщо якась бібліотека не має типів, можна вимкнути помилки для неї:
[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true

Команда для запуску локально або в CI pipeline:

terminal
mypy app/

Частина II: Pydantic v2 — парсинг та валідація даних

Якщо перша частина нашої статті була присвячена тому, як описати наміри типів для статичного аналізатора, то в цій частині ми переходимо до того, як змусити ці типи працювати під час виконання (runtime). Основним інструментом для цього в сучасному Python є бібліотека Pydantic v2.

Філософія «Parse, don't validate»

Більшість систем валідації в інших екосистемах (наприклад, FluentValidation або Data Annotations в C#) працюють за принципом простої перевірки: вони отримують об'єкт, перевіряють, чи відповідає він набору правил (чи заповнені обов'язкові поля, чи пошта має коректний формат), і повертають результат: true або false (з переліком помилок).

Pydantic працює інакше, сповідуючи філософію «Parse, don't validate» (Парси, а не просто валідуй), яка прийшла з функціонального програмування.

  • Валідація — це відповідь на запитання: «Чи є ці дані коректними?».
  • Парсинг — це процес прийому неструктурованих даних (наприклад, JSON-рядка чи HTTP Query параметрів), їх перевірки та перетворення в об'єкти з чітко визначеними типами та структурою.

Коли Pydantic обробляє вхідні дані, він не просто каже, чи вони правильні. Він гарантує, що на виході ви отримаєте об'єкт, типи полів якого стовідсотково відповідають анотаціям. Якщо вхідні дані можна безпечно привести до потрібного типу, Pydantic зробить це автоматично (Coercion):

main.py
from pydantic import BaseModel

class User(BaseModel):
    id: int
    is_active: bool

# Передаємо дані з невідповідними типами, але які можна сконвертувати:
user = User(id="123", is_active="true")

# Pydantic успішно спарсив та привів типи:
print(user.id)         # Виведе: 123 (тип int, а не str)
print(user.is_active)  # Виведе: True (тип bool, а не str)
# Створюємо віртуальне середовище
python -m venv .venv
source .venv/bin/activate  # Для Linux/macOS
# .venv\Scripts\activate   # Для Windows

# Встановлюємо Pydantic
pip install pydantic

# Запускаємо скрипт (припустимо, код збережено у файл main.py)
python main.py
Чому це важливо для вебAPI: Запити з вебу завжди приходять у вигляді текстових рядків (Query params, Headers) або нетипізованого JSON. Автоматичне приведення типів (Type Coercion) позбавляє розробника необхідності вручну писати int(request.query['id']) чи request.query['is_active'] == 'true', зменшуючи кількість бойлерплейт-коду до нуля.

Pydantic проти Dataclasses: у чому різниця?

У Python є вбудований модуль dataclasses (введений у PEP 557, детально розглянутий у статті 09), який теж дозволяє створювати класи для зберігання даних на основі анотацій типів. Чому ж тоді не використовувати їх для вебAPI?

ХарактеристикаDataclassesPydantic
Походження✅ Частина стандартної бібліотеки❌ Зовнішня бібліотека (потребує встановлення)
Runtime-валідація❌ Не робить перевірок під час виконання✅ Строга runtime-валідація даних
Приведення типів (Coercion)❌ Не приводить типи✅ Автоматично конвертує та парсить типи
Серіалізація (JSON)❌ Потребує написання кастомних серіалізаторів✅ Вбудована швидка серіалізація та десеріалізація
Специфікація OpenAPI❌ Не генерує схеми✅ Нативна інтеграція та автоматична генерація схем

Якщо ви створите dataclass та передасте туди некоректні типи, Python не видасть жодної помилки під час виконання:

main.py
from dataclasses import dataclass

@dataclass
class UserDC:
    id: int

# Runtime дозволить це виконати, що призведе до логічних помилок пізніше
user = UserDC(id="not-an-integer")

Pydantic у цьому випадку відразу викине ValidationError, захищаючи межі вашого застонку від брудних даних.


BaseModel під капотом: Метакласи та Дескриптори

Щоб зрозуміти, як працює BaseModel, нам потрібно згадати дві важливі теми з нашого курсу: дескриптори (стаття 07) та метакласи (стаття 08).

Коли ви наслідуєтесь від BaseModel, відбувається наступне:

  1. Збір анотацій: Метаклас Pydantic перехоплює створення класу User. Він аналізує словник __annotations__ та виділяє всі визначені поля та їх типи.
  2. Створення полів: Для кожного поля метаклас динамічно створює дескриптор. Ці дескриптори керують читанням та записом значень атрибутів, відповідаючи за перехоплення спроб присвоїти нові значення та запуск валідації.
  3. Rust-валідатор: У Pydantic v2 ядро написано на Rust (pydantic-core). Метаклас під час ініціалізації класу створює компільовану схему валідації Rust для цього класу. Це забезпечує колосальну швидкість роботи, оскільки безпосередній парсинг та перевірка відбуваються на низькому рівні без оверхеду віртуальної машини Python.

Анатомія Field(): тонке налаштування полів

Функція pydantic.Field() є основним інструментом для розширеного опису полів моделі. Вона дозволяє додавати метадані, налаштовувати аліаси для серіалізації/десеріалізації, а також встановлювати обмеження на значення (валідацію), які будуть перевірятися під час виконання.

default
Any
Встановлює фіксоване значення за замовчуванням для поля. Якщо поле не передано під час створення моделі, воно отримає це значення.
default_factory
Callable[[], Any]
Використовується для встановлення динамічних значень за замовчуванням. Наприклад, default_factory=datetime.utcnow або default_factory=uuid.uuid4. Це запобігає проблемі «мутабельних дефолтів» (mutable defaults) в Python, коли список або словник ділиться між усіма екземплярами класу.
alias
str
Альтернативне ім'я поля, яке використовується як для зчитування (десеріалізації), так і для запису (серіалізації) даних.
validation_alias
str | AliasPath | AliasChoices
Конкретний аліас, який буде використовуватися тільки для валідації (читання вхідного JSON). Корисно, якщо вхідні дані мають дивну структуру або кілька можливих назв.
serialization_alias
str
Конкретний аліас, який буде використовуватися тільки під час експорту моделі (генерації вихідного JSON).
title
str
Людська назва поля, яка потрапляє в специфікацію OpenAPI та відображається в Swagger UI.
description
str
Детальний опис призначення поля, який генерується в документації OpenAPI.
examples
list[Any]
Приклади значень для генерації Swagger-документації.
exclude
bool
Якщо встановлено True, це поле буде повністю виключене з вихідного словника або JSON під час виклику model_dump() чи model_dump_json().
frozen
bool
Робить конкретне поле незмінним (read-only) після ініціалізації об'єкта, навіть якщо сама модель не є frozen.
strict
bool
Вмикає строгий режим перевірки типу лише для цього поля.
gt / ge
num
Greater Than (строго більше) та Greater or Equal (більше або дорівнює). Обмеження для чисел (int, float, Decimal).
lt / le
num
Less Than (строго менше) та Less or Equal (менше або дорівнює). Обмеження для чисел.
multiple_of
num
Вимога, щоб число було кратним вказаному значенню (наприклад, multiple_of=5 дозволяє 5, 10, 15...).
min_length / max_length
int
Обмеження на мінімальну та максимальну кількість символів для рядків (str).
pattern
str | Pattern
Регулярний вираз (Regex), якому має відповідати рядок.

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

main.py
import uuid
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError

class Book(BaseModel):
    # Динамічний дефолт
    id: uuid.UUID = Field(default_factory=uuid.uuid4, description="Унікальний ID книги")
    
    # Обмеження на довжину та приклад
    title: str = Field(min_length=1, max_length=100, examples=["Чистий код"])
    
    # Обмеження на число
    price: float = Field(gt=0, le=1000)
    
    # Виключення з експорту (наприклад, внутрішні системні прапорці)
    is_deleted: bool = Field(default=False, exclude=True)
    
    # Заборона зміни після ініціалізації
    created_at: datetime = Field(default_factory=datetime.utcnow, frozen=True)

# Демонстрація використання та валідації:
try:
    # 1. Створення коректного об'єкта (id та created_at згенеруються автоматично)
    book = Book(title="Чистий код", price=450.00)
    print("Успішно створено книгу:")
    print(f"  ID: {book.id}")
    print(f"  Назва: {book.title}")
    print(f"  Ціна: {book.price}")
    print(f"  Створено: {book.created_at}\n")
    
    # 2. Перевірка роботи параметра exclude=True (is_deleted не буде у вихідному словнику)
    print("Серіалізація моделі (is_deleted виключено):")
    print(f"  {book.model_dump()}\n") 
    
    # 3. Спроба змінити frozen-поле (викликає ValidationError)
    print("Спроба змінити дату створення (frozen=True):")
    book.created_at = datetime.utcnow()
except ValidationError as e:
    print(f"  Помилка валідації: {e.errors()[0]['msg']}\n")

try:
    # 4. Створення об'єкта з некоректними даними (price <= 0)
    print("Створення книги з некоректною ціною (-10.00):")
    invalid_book = Book(title="Некоректна книга", price=-10.00)
except ValidationError as e:
    print(f"  Помилка валідації: {e.errors()[0]['msg']}")
# Створюємо віртуальне середовище та запускаємо
python -m venv .venv
source .venv/bin/activate
pip install pydantic
python main.py

Спеціальні типи в Pydantic: від EmailStr до мережевих адрес

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

1. Валідація пошти: EmailStr

Тип EmailStr призначений для перевірки електронних адрес.

  • Звідки береться: Він імпортується з головного модуля pydantic, але під капотом використовує популярну бібліотеку email-validator.
  • Як працює: Якщо ви спробуєте імпортувати EmailStr або запустити код без встановленої бібліотеки email-validator, Pydantic викине ImportError. Тип перевіряє не тільки наявність символу @, але й коректність структури домену.
  • Встановлення: Для використання потрібно встановити pydantic із розширенням: pip install "pydantic[email]".

2. Захист конфіденційних даних: SecretStr

У вебзастосунках часто потрібно зберігати паролі, API-ключі або токени підключення. Якщо описати їх як звичайний str, вони випадково можуть потрапити до логів (через print або logging) чи вивестись у HTTP-відповідь. SecretStr вирішує цю проблему: він маскує своє значення під час конвертації в рядок або виклику repr(). Для отримання сирого значення потрібно явно викликати метод get_secret_value().

3. Мережеві та DSN типи (HttpUrl, PostgresDsn, IPvAnyAddress)

  • HttpUrl — перевіряє схему URL (очікує http або https), наявність хоста та правильність структури посилання.
  • PostgresDsn / RedisDsn — валідує рядки підключення (Data Source Name). Розбирає URI на компоненти (user, password, host, port, path).
  • IPvAnyAddress — валідує IPv4 або IPv6 адресу.

Приклад використання спеціальних типів:

main.py
from pydantic import BaseModel, EmailStr, HttpUrl, PostgresDsn, SecretStr
from pydantic.networks import IPvAnyAddress

class SystemConfig(BaseModel):
    admin_email: EmailStr
    database_url: PostgresDsn
    api_key: SecretStr
    allowed_ip: IPvAnyAddress
    project_website: HttpUrl

config = SystemConfig(
    admin_email="admin@kostyl.dev",
    database_url="postgresql://user:pass@localhost:5432/dbname",
    api_key="super-secret-key-123",
    allowed_ip="192.168.1.1",
    project_website="https://kostyl.dev"
)

# Значення api_key замасковано:
print(config.api_key)  # Виведе: SecretStr('**********')
print(repr(config.api_key))  # Виведе: SecretStr('**********')

# Отримання реального пароля (робити свідомо!):
print(config.api_key.get_secret_value())  # Виведе: "super-secret-key-123"

# Звернення до частин URI без регулярних виразів:
print(config.database_url.port)  # Виведе: 5432
print(config.database_url.host)  # Виведе: "localhost"
# Встановлюємо pydantic разом із підтримкою email-validator
python -m venv .venv
source .venv/bin/activate
pip install "pydantic[email]"

# Запускаємо скрипт
python main.py

Механізми валідації: Декоратори та класи-валідатори

Pydantic v2 пропонує два основні підходи для написання кастомних правил валідації: класичний (через декоратори у класі моделі) та сучасний (через класи-валідатори та Annotated).

1. Декоратори @field_validator та @model_validator

Це найпоширеніший спосіб додавання бізнес-правил безпосередньо у модель.

  • @field_validator — перевіряє конкретне поле. Метод обов'язково має бути classmethod.
  • @model_validator — перевіряє модель цілкома (корисно для перевірки залежностей між кількома полями, наприклад, коли дата завершення має бути пізнішою за дату початку).

Приклад реалізації обох валідаторів:

main.py
from datetime import datetime
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationError

class ProjectCreate(BaseModel):
    name: str = Field(min_length=3, max_length=50)
    start_date: datetime
    end_date: datetime

    @field_validator("name")
    @classmethod
    def validate_name_capitalization(cls, value: str) -> str:
        # Ім'я проєкту має починатися з великої літери
        if not value[0].isupper():
            raise ValueError("Ім'я проєкту має починатися з великої літери")
        return value

    @model_validator(mode="after")
    def validate_date_range(self) -> "ProjectCreate":
        # self вже є типізованим об'єктом моделі (бо mode="after")
        if self.end_date <= self.start_date:
            raise ValueError("Дата завершення має бути пізнішою за дату початку")
        return self

# Демонстрація роботи валідаторів:
try:
    # 1. Створення коректного проєкту
    project = ProjectCreate(
        name="TaskForge",
        start_date=datetime(2026, 1, 1),
        end_date=datetime(2026, 12, 31)
    )
    print("Успішно створено проєкт:")
    print(f"  Назва: {project.name}")
    print(f"  Період: {project.start_date.date()} -> {project.end_date.date()}\n")

    # 2. Некоректне ім'я (починається з маленької літери)
    print("Спроба створити проєкт з маленької літери:")
    invalid_name = ProjectCreate(
        name="taskforge",
        start_date=datetime(2026, 1, 1),
        end_date=datetime(2026, 12, 31)
    )
except ValidationError as e:
    print(f"  Помилка валідації: {e.errors()[0]['msg']}\n")

try:
    # 3. Некоректні дати (дата завершення передує даті початку)
    print("Спроба створити проєкт з неправильним діапазоном дат:")
    invalid_dates = ProjectCreate(
        name="TaskForge",
        start_date=datetime(2026, 12, 31),
        end_date=datetime(2026, 1, 1)
    )
except ValidationError as e:
    print(f"  Помилка валідації: {e.errors()[0]['msg']}")
# Створюємо віртуальне середовище та запускаємо
python -m venv .venv
source .venv/bin/activate
pip install pydantic
python main.py
Параметр mode у @model_validator:
  • mode="before": метод викликається до перевірки типів Pydantic. Він отримує сирий словник (dict[str, Any]), який прийшов від користувача. Це корисно для попередньої обробки ключів (наприклад, якщо назви полів прийшли в CamelCase, а треба PascalCase).
  • mode="after": метод викликається після успішної перевірки типів. Він працює з готовим екземпляром класу, що гарантує наявність полів правильного типу.

2. Сучасний підхід: BeforeValidator та AfterValidator

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

  • BeforeValidator: запускається перед стандартними перевірками Pydantic (працює з сирими даними).
  • AfterValidator: запускається після стандартних перевінок Pydantic (працює з вже приведеніми типами).
main.py
from typing import Annotated
from pydantic import BaseModel, AfterValidator, ValidationError

def ensure_trimmed(value: str) -> str:
    # Валідатор, який очищає пробіли по краях
    return value.strip()

def ensure_not_empty(value: str) -> str:
    if not value:
        raise ValueError("Рядок не може бути порожнім")
    return value

# Створюємо перевикористовуваний тип
CleanString = Annotated[str, AfterValidator(ensure_trimmed), AfterValidator(ensure_not_empty)]

class UserProfile(BaseModel):
    username: CleanString
    bio: CleanString

# Демонстрація роботи валідаторів:
try:
    # 1. Створення профілю з пробілами по краях полів
    profile = UserProfile(username="  alex_smith  ", bio="  Software Engineer from kostyl.dev  ")
    print("Успішно створено профіль (пробіли автоматично обрізано):")
    print(f"  username: '{profile.username}'")
    print(f"  bio: '{profile.bio}'\n")
    
    # 2. Спроба передати порожній рядок (або рядок лише з пробілів)
    print("Спроба створити профіль з порожнім username:")
    invalid_profile = UserProfile(username="   ", bio="Developer")
except ValidationError as e:
    print(f"  Помилка валідації: {e.errors()[0]['msg']}")
# Запуск прикладу з Before/After валідаторами
python -m venv .venv
source .venv/bin/activate
pip install pydantic
python main.py
Чому це краще за класичний підхід: У великих проєктах правила на кшталт "очистити пробіли" або "перевірити формат телефону" потрібні для десятків різних полів у різних моделях. Замість копіювання методів @field_validator у кожен клас, ми створюємо один типізований аліас (наприклад, PhoneNumber = Annotated[str, AfterValidator(validate_phone)]) і використовуємо його всюди.

Анатомія помилки: ValidationError

Коли вхідні дані не відповідають описаній схемі, Pydantic викидає виняток ValidationError. Для розробників з екосистеми C#, де винятки часто бувають текстовими або містять кастомні структури помилок, structured формат ValidationError у Pydantic є надзвичайно зручним інструментом.

Об'єкт ValidationError містить повну інформацію про всі виявлені проблеми. Викликавши метод e.errors(), ви отримаєте список словників, кожен з яких описує конкретну помилку:

data.json
[
  {
    "type": "less_than",
    "loc": ["price"],
    "msg": "Input should be greater than 0",
    "input": -10.0,
    "ctx": {"gt": 0.0},
    "url": "https://errors.pydantic.dev/2.10/v/less_than"
  }
]
loc
tuple[str | int, ...]
Шлях до помилкового поля. Якщо помилка сталася у вкладеній структурі, loc міститиме повний ланцюжок ключів та індексів. Наприклад, ("items", 0, "price") вказує на те, що помилка сталася в першому елементі списку items у полі price.
input
Any
Значення, яке було передано на вхід і не пройшло валідацію (корисно для логування та дебагу).
type
str
Унікальний ідентифікатор типу помилки (наприклад, missing, int_parsing, value_error). Використовується для програмної обробки та локалізації.
msg
str
Зрозуміле повідомлення про помилку англійською мовою.
url
str
Посилання на детальну документацію Pydantic з описом цієї конкретної помилки та шляхами її виправлення.

Поліморфна валідація: Discriminated Unions

У веброзробці часто виникає потреба обробляти поліморфні дані — коли тип об'єкта визначається спеціальним полем-маркером (дискримінатором). У C# для цього зазвичай використовуються атрибути поліморфізму System.Text.Json (наприклад, [JsonPolymorphic]).

У Pydantic v2 це реалізується через Discriminated Unions за допомогою об'єднання типів (Union або |) та вказівки дискримінатора у Field():

main.py
from typing import Literal, Union
from pydantic import BaseModel, Field, ValidationError

class ProgrammingTask(BaseModel):
    type: Literal["code"] = "code" # Поле-дискримінатор
    language: str
    repository: str

class DesignTask(BaseModel):
    type: Literal["design"] = "design" # Поле-дискримінатор
    tool: str
    pages_count: int

# Об'єднуємо моделі та вказуємо поле-дискримінатор
class TaskForm(BaseModel):
    task: Union[ProgrammingTask, DesignTask] = Field(..., discriminator="type")

# Демонстрація поліморфного парсингу:
try:
    # 1. Передаємо дані для кодингу
    form1 = TaskForm(task={"type": "code", "language": "Python", "repository": "github.com"})
    print("Успішно спарсено задачу програмування:")
    print(f"  Тип: {type(form1.task)}")
    print(f"  Мова: {form1.task.language}\n")

    # 2. Передаємо дані для дизайну
    form2 = TaskForm(task={"type": "design", "tool": "Figma", "pages_count": 5})
    print("Успішно спарсено задачу дизайну:")
    print(f"  Тип: {type(form2.task)}")
    print(f"  Кількість сторінок: {form2.task.pages_count}\n")

    # 3. Передаємо некоректний тип
    form3 = TaskForm(task={"type": "unknown_type"})
except ValidationError as e:
    print(f"Помилка валідації поліморфної форми:\n  {e.errors()[0]['msg']}")
python -m venv .venv
source .venv/bin/activate
pip install pydantic
python main.py

Валідація без створення моделей: TypeAdapter

У Pydantic v1 для валідації простих типів або списків безпосередньо (наприклад, перевірити, чи є вхідні дані просто списком цілих чисел list[int]) доводилося створювати тимчасові «фейкові» моделі.

У Pydantic v2 з'явився інструмент TypeAdapter, який дозволяє валідувати та серіалізувати будь-які типи, сумісні з PEP 484, без створення класу BaseModel.

main.py
from pydantic import TypeAdapter, ValidationError

# Створюємо адаптер для списку цілих чисел
integer_list_adapter = TypeAdapter(list[int])

try:
    # 1. Успішна валідація та парсинг
    result = integer_list_adapter.validate_python(["1", 2, "3"])
    print(f"Спарсено список чисел: {result}") # Виведе: [1, 2, 3]

    # 2. Некоректні дані
    integer_list_adapter.validate_python(["one", 2])
except ValidationError as e:
    print(f"Помилка валідації списку: {e.errors()[0]['msg']}")
python -m venv .venv
source .venv/bin/activate
pip install pydantic
python main.py

Серіалізація даних: Експорт моделей

Окрім парсингу та валідації вхідних даних, Pydantic відповідає за зворотний процес — серіалізацію (експорт) об'єктів у словники чи JSON-рядки. Це життєво необхідно для формування HTTP-відповідей.

У Pydantic v2 всі експортні методи отримали префікс model_:

  • model_dump() — серіалізує модель у звичайний Python-словник (dict).
  • model_dump_json() — серіалізує модель безпосередньо в JSON-рядок (працює значно швидше, ніж json.dumps(model.model_dump()), оскільки конвертація відбувається на рівні Rust).
  • model_json_schema() — генерує словник, що представляє JSON Schema моделі (основа автодокументації FastAPI).

Тонке керування серіалізацією

Методи model_dump та model_dump_json приймають важливі параметри для фільтрації полів:

  • exclude_unset: експортує лише ті поля, які були явно передані при створенні об'єкта (ігнорує дефолтні значення).
  • exclude_none: виключає зі словника поля, значення яких дорівнюють None.
  • include / exclude: дозволяє явно вказати набір полів для експорту (наприклад, виключити пароль користувача).
main.py
from pydantic import BaseModel, Field

class UserResponse(BaseModel):
    id: int
    email: str
    username: str | None = None
    role: str = "user"

# Створюємо об'єкт (передаємо тільки id та email)
user = UserResponse(id=1, email="admin@test.com")

# Експорт у словник з ігноруванням None та дефолтних полів
print(user.model_dump(exclude_unset=True))
# Виведе: {'id': 1, 'email': 'admin@test.com'}

# Експорт з виключенням певних полів
print(user.model_dump(exclude={"email"}))
# Виведе: {'id': 1, 'username': None, 'role': 'user'}
# Запуск прикладу серіалізації
python -m venv .venv
source .venv/bin/activate
pip install pydantic
python main.py

Налаштування моделей: ConfigDict

Поведінку кожної моделі Pydantic можна гнучко налаштовувати. У Pydantic v2 для цього використовується спеціальний словник конфігурації ConfigDict (у v1 використовувався вкладений клас class Config).

Приклад використання основних параметрів конфігурації:

main.py
from pydantic import BaseModel, ConfigDict, Field

class UserModel(BaseModel):
    # Налаштування конфігурації моделі
    model_config = ConfigDict(
        strict=True,                  # Вмикає строгий режим (забороняє приведення типів "123" -> 123)
        populate_by_name=True,        # Дозволяє створювати об'єкт як за аліасами, так і за іменами полів
        extra="forbid",               # Забороняє передачу додаткових невідомих полів (захист від Over-posting)
        str_strip_whitespace=True     # Автоматично видаляє пробіли по краях у всіх рядках
    )

    id: int
    # Аліас дозволяє мапити зовнішні назви полів (наприклад, з JSON в стилі snake_case/camelCase)
    first_name: str = Field(alias="firstName")

# Створення об'єкта за допомогою аліасу (наприклад, при отриманні JSON від фронтенду)
user_alias = UserModel(id=1, firstName="  Іван  ")
print(user_alias.first_name)  # Виведе: "Іван" (пробіли видалено завдяки str_strip_whitespace)

# Завдяки populate_by_name=True, працює також створення через реальне ім'я атрибута:
user_name = UserModel(id=2, first_name="Петро")
# Запуск прикладу з ConfigDict
python -m venv .venv
source .venv/bin/activate
pip install pydantic
python main.py
Чому extra="forbid" важливий для безпеки: В веброзробці існує атака типу Over-posting (або Mass Assignment), коли зловмисник передає в запиті додаткові поля, які сервер не очікує (наприклад, is_admin=True при реєстрації). Якщо ваша модель просто ігнорує зайві поля, це може призвести до вразливостей, якщо ви потім передаєте весь словник у базу даних. Конфігурація extra="forbid" відразу поверне клієнту помилку 422 Unprocessable Entity, заблокувавши атаку.

Керування конфігурацією застосунку: Pydantic Settings

У сучасній розробці (відповідно до методології 12-Factor App) конфігурація застосунку має зберігатися у змінних середовища (environment variables).

В екосистемі Python стандартом де-факто для зчитування конфігурації, її типізації та валідації є бібліотека Pydantic Settings (у v2 вона винесена в окремий пакет pydantic-settings).

Вона автоматично:

  1. Зчитує змінні середовища (наприклад, DATABASE_URL).
  2. Якщо змінних немає в системі, намагається зчитати їх із файлу .env.
  3. Парсить і конвертує типи (наприклад, "5432" -> 5432 для int).
  4. Робить валідацію (наприклад, перевіряє правильність формату URL).

Приклад реалізації класу налаштувань:

main.py
from pydantic import PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    # Вказуємо назву файлу .env для зчитування налаштувань локально
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore" # Ігноруємо зайві змінні в системі
    )

    db_url: PostgresDsn
    redis_url: RedisDsn | None = None
    app_port: int = 8000
    debug_mode: bool = False

# Створюємо глобальний синглтон налаштувань (ініціалізується один раз)
settings = Settings()

# Тепер налаштування доступні по всьому застосунку з автодоповненням в IDE:
print(f"Server starting on port: {settings.app_port}")
print(f"DB Host: {settings.db_url.host}")
# Для роботи з налаштуваннями потрібен пакет pydantic-settings
python -m venv .venv
source .venv/bin/activate
pip install pydantic pydantic-settings

# Запускаємо (переконайтеся, що файл .env існує або змінні задані в системі)
python main.py
Чому PostgresDsn — це-круто: Тип PostgresDsn — це спеціальний тип Pydantic для валідації URI підключення до PostgreSQL. Він не просто перевіряє рядок регулярним виразом, а розбирає його на окремі частини. Ви можете звертатися до settings.db_url.host, settings.db_url.username, settings.db_url.password, settings.db_url.port напряму без додаткового парсингу рядка!

Продуктивність Pydantic v2: Революція під капотом

Чому Pydantic став настільки популярним, що його використовують не тільки у FastAPI, але й у великих LLM фреймворках (LangChain, LlamaIndex), сервісах на кшталт OpenAI SDK та інструментах типу ruff?

Головна причина — швидкість.

У версії v1 Pydantic був написаний на чистому Python. Під час інтенсивної роботи з даними (наприклад, серіалізація тисяч записів з бази даних) це створювало великий I/O-bound оверхед.

У v2 всю внутрішню логіку валідації та парсингу було повністю переписано на Rust (бібліотека pydantic-core).

Бенчмарки продуктивності (операцій на секунду, більше = краще):

Валідація JSON:
┌───────────────────────────┬──────────────────────────────────────────┐
│ Pydantic v1 (Pure Python) │ █ 1x (базовий рівень)                     │
├───────────────────────────┼──────────────────────────────────────────┤
│ Pydantic v2 (Rust Core)   │ ██████████████ 14x - 17x швидше          │
└───────────────────────────┴──────────────────────────────────────────┘

Це робить FastAPI одним із найшвидших вебфреймворків у динамічних мовах програмування, максимально наближаючи його продуктивність до рішень на Go чи Node.js.


Порівняння валідації: ASP.NET Core vs Pydantic

Для розробника, який переходить із C#, концепція валідації у Pydantic є більш цілісною, оскільки вона об'єднує синтаксичну простоту декларативного оголошення полів та потужність кастомних бізнес-перевірок.

У .NET-світі для валідації зазвичай використовуються два підходи:

  1. Data Annotations (атрибути типу [Required], [EmailAddress], [Range(18, 100)] над властивостями класу).
  2. FluentValidation (окремий клас валідатора, який наслідується від AbstractValidator<T> та описує правила за допомогою Fluent API).

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

using System.ComponentModel.DataAnnotations;

public class UserRegisterModel
{
    [Required]
    [EmailAddress]
    public string Email { get; set; }

    [Range(18, 120, ErrorMessage = "Вік має бути від 18 років")]
    public int Age { get; set; }

    [StringLength(50, MinimumLength = 3)]
    public string Username { get; set; }
}

Ключові відмінності

  • Зв'язок із типами: У C# атрибути Data Annotations не змінюють тип даних. Якщо ви передасте рядок "18" у поле int Age, MVC-біндер спробує його конвертувати, але у разі невдачі запише помилку у ModelState. У Pydantic тип є первинним контрактом: якщо тип поля int, Pydantic під капотом спробує виконати безпечний парсинг і автоматично приведе типи, або викине структурований ValidationError.
  • Локалізація помилок: В ASP.NET Core для кастомізації помилок використовуються параметри атрибутів ErrorMessage. У Pydantic помилки валідації повертають структурований JSON зі списком полів, типом помилки та повідомленням. Локалізація часто робиться на клієнті або через кастомний обробник виключень (Exception Handler) у FastAPI.

Практичний приклад: Система обробки платіжних Webhook-ів

Для закріплення отриманих знань розберемо створення закінченого мікросервісного рішення для обробки вхідних Webhook-ів від платіжної системи (наприклад, Stripe). Наше завдання — зчитати конфігурацію системи, перевірити вхідні HTTP-payloads, автоматично розпізнати тип події (успішний платіж чи повернення коштів) за допомогою Discriminated Unions та виконати бізнес-логіку.

Проєкт матиме наступну структуру:

payment_processor/
├── .env
├── config.py
├── schemas.py
└── main.py

Крок 1: Створення файлу конфігурації .env та config.py

Почнемо з налаштування параметрів безпеки. Нам потрібні:

  1. SIGNING_SECRET — ключ, за допомогою якого перевіряється справжність запиту.
  2. API_URL — посилання на бекенд платіжної системи.
  3. MAX_PAYMENT_LIMIT — обмеження суми за одну транзакцію.

Створіть файл .env:

SIGNING_SECRET="stripe_whsec_supersecret_key_123"
API_URL="https://api.stripe.com/v3"
MAX_PAYMENT_LIMIT=50000.00

Створіть файл config.py, що зчитує та валідує ці змінні:

main.py
from pydantic import HttpUrl, SecretStr, Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    # Зберігаємо секретний ключ захищено
    signing_secret: SecretStr
    
    # Валідуємо правильність формату посилання
    api_url: HttpUrl
    
    # Максимальна сума транзакції
    max_payment_limit: float = Field(default=10000.00, gt=0)

settings = Settings()

Крок 2: Опис моделей платіжних подій (schemas.py)

Наш сервіс обробляє два типи подій від платіжного провайдера: payment.succeeded (успішна оплата) та payment.refunded (повернення коштів). Вони надходять на один ендпоінт. Опишемо схеми даних.

Створіть файл schemas.py:

main.py
from datetime import datetime
from typing import Literal, Union
from pydantic import BaseModel, Field, EmailStr, field_validator

class PaymentSucceededData(BaseModel):
    transaction_id: str = Field(min_length=10, max_length=50)
    amount: float = Field(gt=0)
    currency: str = Field(min_length=3, max_length=3)
    customer_email: EmailStr

class RefundedData(BaseModel):
    transaction_id: str
    original_amount: float
    refund_reason: str = Field(min_length=5)

# Базова схема платіжного Webhook-а з дискримінатором
class PaymentWebhookEvent(BaseModel):
    # Поле type виступає маркером для розділення схем
    event_type: Literal["payment.succeeded", "payment.refunded"] = Field(..., alias="type")
    created_at: datetime = Field(default_factory=datetime.utcnow)
    
    # Залежно від event_type, Pydantic обере правильну вкладену модель
    data: Union[PaymentSucceededData, RefundedData]

    @field_validator("data")
    @classmethod
    def validate_amount_limit(cls, data_model):
        # Імпортуємо налаштування для перевірки ліміту
        from config import settings
        
        # Перевіряємо суму, якщо це подія успішного платежу
        if isinstance(data_model, PaymentSucceededData):
            if data_model.amount > settings.max_payment_limit:
                raise ValueError(
                    f"Сума транзакції {data_model.amount} перевищує встановлений ліміт {settings.max_payment_limit}"
                )
        return data_model

Крок 3: Точка входу та логіка обробки подій (main.py)

Тепер напишемо сервісний шар, який приймає сирі JSON-дані, запускає парсинг Pydantic і обробляє результати.

Створіть файл main.py:

main.py
import json
from pydantic import ValidationError
from config import settings
from schemas import PaymentWebhookEvent, PaymentSucceededData, RefundedData

def handle_incoming_webhook(raw_payload: str, signature: str) -> None:
    # 1. Перевірка підпису (Імітація захисту)
    # Зіставляємо сирий signature з налаштуваннями із .env
    if signature != settings.signing_secret.get_secret_value():
        print("❌ Помилка безпеки: підпис запиту невірний!")
        return

    # 2. Валідація та парсинг за допомогою Pydantic
    try:
        data_dict = json.loads(raw_payload)
        # Pydantic автоматично розбере тип data на основі дискримінатора 'type'
        event = PaymentWebhookEvent(**data_dict)
        
        print(f"✅ Webhook успішно верифіковано. Тип: {event.event_type}")
        print(f"   Дата створення: {event.created_at}")

        # 3. Обробка бізнес-логіки
        if isinstance(event.data, PaymentSucceededData):
            process_payment(event.data)
        elif isinstance(event.data, RefundedData):
            process_refund(event.data)

    except ValidationError as e:
        print("❌ Помилка валідації вхідних даних Webhook:")
        for error in e.errors():
            # Виводимо точне місце виникнення помилки
            path = " -> ".join(str(p) for p in error["loc"])
            print(f"   Поле: [{path}] | Помилка: {error['msg']} | Отримано: {error.get('input')}")

def process_payment(data: PaymentSucceededData) -> None:
    print(f"💳 [БІЗНЕС-ЛОГІКА] Зараховано платіж {data.amount} {data.currency}")
    print(f"   Клієнт: {data.customer_email} | ID транзакції: {data.transaction_id}\n")

def process_refund(data: RefundedData) -> None:
    print(f"🔄 [БІЗНЕС-ЛОГІКА] Оформлено повернення коштів для транзакції {data.transaction_id}")
    print(f"   Причина: {data.refund_reason} | Сума: {data.original_amount}\n")


# --- Тестові сценарії (симуляція роботи вебсервера) ---
if __name__ == "__main__":
    correct_signature = "stripe_whsec_supersecret_key_123"

    # Сценарій 1: Успішний платіж
    payload_success = {
        "type": "payment.succeeded",
        "data": {
            "transaction_id": "tx_992019a82bb",
            "amount": 250.50,
            "currency": "USD",
            "customer_email": "student@kostyl.dev"
        }
    }
    
    # Сценарій 2: Повернення коштів
    payload_refund = {
        "type": "payment.refunded",
        "data": {
            "transaction_id": "tx_992019a82bb",
            "original_amount": 250.50,
            "refund_reason": "Клієнт передумав купувати курс"
        }
    }

    # Сценарій 3: Помилка ліміту оплати (amount > max_payment_limit)
    payload_invalid_limit = {
        "type": "payment.succeeded",
        "data": {
            "transaction_id": "tx_too_large_amount_value",
            "amount": 999999.00,
            "currency": "USD",
            "customer_email": "rich_hacker@test.com"
        }
    }

    # Сценарій 4: Помилка структури (некоректний email)
    payload_invalid_email = {
        "type": "payment.succeeded",
        "data": {
            "transaction_id": "tx_short", # < 10 символів
            "amount": 100.00,
            "currency": "USD",
            "customer_email": "not-an-email"
        }
    }

    print("=== ТЕСТ 1: Успішний платіж ===")
    handle_incoming_webhook(json.dumps(payload_success), correct_signature)

    print("=== ТЕСТ 2: Оформлення повернення ===")
    handle_incoming_webhook(json.dumps(payload_refund), correct_signature)

    print("=== ТЕСТ 3: Порушення ліміту безпеки ===")
    handle_incoming_webhook(json.dumps(payload_invalid_limit), correct_signature)

    print("=== ТЕСТ 4: Помилка валідації пошти та довжини ID ===")
    handle_incoming_webhook(json.dumps(payload_invalid_email), correct_signature)

Крок 4: Встановлення залежностей та запуск проєкту

Створіть необхідні файли у вашій системі та перевірте правильність роботи скрипта.

# Створюємо директорію проєкту та переходимо
mkdir -p payment_processor
cd payment_processor

# Створюємо віртуальне оточення
python -m venv .venv
source .venv/bin/activate

# Встановлюємо залежності: pydantic, pydantic-settings та email-validator
pip install pydantic pydantic-settings "pydantic[email]"

# Запускаємо скрипт
python main.py

Лог вихідних даних запуску програми:

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

=== ТЕСТ 1: Успішний платіж ===
✅ Webhook успішно верифіковано. Тип: payment.succeeded
   Дата створення: 2026-06-27 15:55:00.123456
💳 [БІЗНЕС-ЛОГІКА] Зараховано платіж 250.5 USD
   Клієнт: student@kostyl.dev | ID транзакції: tx_992019a82bb

=== ТЕСТ 2: Оформлення повернення ===
✅ Webhook успішно верифіковано. Тип: payment.refunded
   Дата створення: 2026-06-27 15:55:00.123890
🔄 [БІЗНЕС-ЛОГІКА] Оформлено повернення коштів для транзакції tx_992019a82bb
   Причина: Клієнт передумав купувати курс | Сума: 250.5

=== ТЕСТ 3: Порушення ліміту безпеки ===
❌ Помилка валідації вхідних даних Webhook:
   Поле: [data] | Помилка: Value error, Сума транзакції 999999.0 перевищує встановлений ліміт 50000.0 | Отримано: {'transaction_id': 'tx_too_large_amount_value', 'amount': 999999.0, 'currency': 'USD', 'customer_email': 'rich_hacker@test.com'}

=== ТЕСТ 4: Помилка валідації пошти та довжини ID ===
❌ Помилка валідації вхідних даних Webhook:
   Поле: [data -> transaction_id] | Помилка: String should have at least 10 characters | Отримано: tx_short
   Поле: [data -> customer_email] | Помилка: value is not a valid email address: The email address is not valid. It must have exactly one @-sign. | Отримано: not-an-email

Практичні завдання

Для закріплення матеріалу виконайте наступні завдання. Збережіть ваші рішення у окремі файли та перевірте правильність роботи за допомогою mypy та запуску коду.

Рівень 1 (Базовий): Створення моделі користувача

Створіть Pydantic-модель UserCreate, яка містить:

  • email: обов'язковий email (використовуйте EmailStr).
  • age: ціле число від 18 до 100 років.
  • created_at: дата реєстрації (об'єкт datetime), яка не повинна бути в майбутньому (реалізуйте через @field_validator).

Рівень 2 (Середній): Вкладені моделі та бізнес-правила

Спроектуйте моделі для замовлення інтернет-магазину:

  • Product: має id (int), name (str) та price (float, яка має бути строго більшою за 0).
  • OrderItem: містить об'єкт Product та quantity (int, строго більше 0).
  • Order: містить список items (список OrderItem) та total_price (float). Додайте @model_validator(mode="after") для класу Order, який перевіряє, що значення total_price точно збігається із сумою цін усіх товарів, помножених на їхню кількість (price * quantity).

Рівень 3 (Професійний): Generic-валідатор та Annotated

Напишіть кастомний валідатор для конвертації валют. Створіть перевикористовуваний тип PriceUSD, який є анотацією для float. За допомогою BeforeValidator перевірте, чи є вхідне значення рядком типу "$100.50" або "100.50$" чи числом. Валідатор має автоматично очистити символ $ і повернути чисте значення типу float. Якщо конвертація неможлива — викиньте ValueError.

Copyright © 2026