Создаем инстансы нужных моделей Pydantic по входным данным

Задача

Рассмотрим практическую задачу в несколько упрощенном виде. Допустим, бэкенду нужно по полученным с фронта данным – названию ресурса API (скажем, их несколько десятков), и специфичным для каждого ресурса параметрам (произвольный набор и количество для каждого) сделать запрос к соответствующему ресурсу API с переданным набором параметров.

Pydantic banner

Можно, конечно, использовать условные операторы, таблицы принятия решений и т.п., но гораздо проще использовать Pydantic (тем более, если он уже есть в проекте) для решения данной задачи.

Ниже на примере пошагово рассмотрим, как это реализовать.

Предлагаемая реализация

Предположим, что в списке - варианты данных, которые мы можем получить с фронта: ресурс API, к которому надо обратиться, и специфичные параметры для запроса.

data = [
    {"resource_name": "user", "username": "hazadus"},
    {"resource_name": "client", "phone": "(812) 355-66-55"},
    {"resource_name": "note", "text": "Buy the milk"},
]

Опишем базовую модель ресурса следующим образом:

class BaseResource(BaseModel):
    # В этой модели могут быть поля, общие для всех ресурсов, например host, auth_token, etc.
    # Опустим их для краткости

    http_method: ClassVar[str] = "GET"

    def _get_request_url(self) -> str: ...
    def _get_headers(self) -> dict: ...
    def _get_json(self) -> dict: ...

    def request(self) -> str:
        """Условный запрос к API используя указанный метод, URL и данные"""
        return f"{self.http_method} {self._get_request_url()} data={self._get_json()}"

Далее, наследуем BaseResource и описываем данные и поведение индивидуальных классов, реализующих доступ к определенным ресурсам и методам:

class User(BaseResource):
    # Для каждого ресурса укажем его тип в `resource_name`. По этому полю Pydantic будет отличать ресурсы
    resource_name: Literal["user"]

    # Это поле мы планиуем получить из входных данных, оно будет валидироваться
    username: str

    # Константа, которой не должно быть в данных
    # Ref: https://docs.pydantic.dev/2.6/errors/usage_errors/#model-field-missing-annotation
    endpoint: ClassVar[str] = "/users/"
    http_method: ClassVar[str] = "GET"

    # специфичная логика запроса к ресурсу прописывается здесь, общую
    # нужно вынести в базовый класс
    def _get_request_url(self):
        return f"http://localhost{self.endpoint}{self.username}/?order=desc"


class Client(BaseResource):
    resource_name: Literal["client"]

    phone: str

    endpoint: ClassVar[str] = "/clients/"
    http_method: ClassVar[str] = "POST"

    def _get_request_url(self):
        return f"http://localhost{self.endpoint}"

    def _get_json(self) -> dict:
        return {"phone": self.phone}


class Note(BaseResource):
    resource_name: Literal["note"]

    text: str

    endpoint: ClassVar[str] = "/notes/"
    http_method: ClassVar[str] = "POST"

    def _get_request_url(self):
        return f"http://localhost{self.endpoint}"

    def _get_json(self) -> dict:
        return {"text": self.text}

Самая «изюминка» происходит в следующей части примера:

class API(BaseModel):
    # При помощи union перечисляем, какого типа может быть свойство resource
    # и указываем дискриминатор - поле, по которому будет идентифицирован нужный тип
    resource: User | Client | Note = Field(..., discriminator="resource_name")


for json in data:
    try:
        # ✨ Магия происходит здесь:
        api = API(resource=json)
        # `api.resource` будет инстансом нужного класса, в зависимости от `json.resource_name`!
        # Поэтому `api.resource.request()` запустит специфичную для нужного ресурса логику запроса:
        print(api.resource.request())
    except ValidationError as ex:
        print("\n\nWrong resource type or incorrect params:", ex)

Результат работы примера будет следующим:

GET http://localhost/users/hazadus/?order=desc data=None
POST http://localhost/clients/ data={'phone': '(812) 355-66-55'}
POST http://localhost/notes/ data={'text': 'Buy the milk'}


Wrong resource type or incorrect params: 1 validation error for API
resource.note.text
  Field required [type=missing, input_value={'resource_name': 'note'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.6/v/missing


Wrong resource type or incorrect params: 1 validation error for API
resource
  Input tag 'unknown' found using 'resource_name' does not match any of the expected tags: 'user', 'client', 'note' [type=union_tag_invalid, input_value={'resource_name': 'unknown'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.6/v/union_tag_invalid

Мы видим, что по корректным входным данным были созданы инстансы требуемых моделей. В случае неверных данных (они есть в полном примере), ошибка, выдаваемая Pydantic, подробнейшим образом описывает, что пошло не так.

Рассмотренное решение работает благодаря фиче Pydantic под названием Discriminated Unions. В документации есть более компактный пример и описание этой возможности на котиках и собачках.

В реальном коде кроме resource_name стоит добавить еще поле method для обращения к различным методам ресурса, и создать соответствующие подклассы, например NotePost, NoteGetById, NotePatch и т.п., прописав в них только специфичные параметры и логику – всё остальное сделает базовый класс.

Надеюсь, эта крутая фишка Pydantic пригодится и вам. Мне она помогла радикально сократить и упростить код при написании микросервиса для интеграции с большим количеством методов внешнего API!

Дополнительные материалы

Отправить сообщение

С помощью формы ниже, вы можете связаться с автором сайта. Пожалуйста, укажите ваш ник в Телеграме или e-mail, чтобы я смог вам ответить!