Json api сервис на aiohttp: middleware и валидация

  • Tutorial

В этой статье я опишу один из подходов для создания json api сервиса с валидацией данных.


Сервис будет реализован на aiohttp. Это современный, постоянно развивающийся фреймворк на языке python, использующий asyncio.


Об аннотациях:


Появление аннотаций в python позволило сделать код более понятным. Так же, аннотации открывают некоторые дополнительные возможности. Именно аннотации играют ключевую роль при валидации данных у обработчиков api-методов в этой статье.


Используемые библиотеки:


  • aiohttp — фреймворк для создания web-приложений
  • pydantic — классы, которые позволяют декларативно описывать данные и валидировать их
  • valdec — декоратор для валидации аргументов и возвращаемых значений у функций

Оглавление:



1. Файлы и папки приложения


- sources - Папка с кодом приложения
    - data_classes - Папка с модулями классов данных
        - base.py - базовый класс данных
        - person.py - классы данных о персоне
        - wraps.py - классы данных оболочек для запросов/ответов
    - handlers - Папка с модулями обработчиков запросов
        - kwargs.py - обработчики для примера работы с `KwargsHandler.middleware`
        - simple.py - обработчики для примера работы с `SimpleHandler.middleware`
        - wraps.py - обработчики для примера работы с `WrapsKwargsHandler.middleware`
    - middlewares - Папка с модулями для middlewares
        - exceptions.py - классы исключений
        - kwargs_handler.py - класс `KwargsHandler`
        - simple_handler.py - класс `SimpleHandler`
        - utils.py - вспомогательные классы и функции для middlewares
        - wraps_handler.py - класс `WrapsKwargsHandler`
    - requirements.txt - зависимости приложения
    - run_kwargs.py - запуск с `KwargsHandler.middleware`
    - run_simple.py - запуск c `SimpleHandler.middleware`
    - run_wraps.py - запуск c `WrapsKwargsHandler.middleware`
    - settings.py - константы с настройками приложения
- Dockerfile - докерфайл для сборки образа

Код доступен на гитхаб: https://github.com/EvgeniyBurdin/api_service


2. json middlewares


middleware в aiohttp.web.Application() является оболочкой для обработчиков запросов.


Если в приложении используется middleware, то поступивший запрос сначала попадает в неё, и только потом передается в обработчик. Обработчик формирует и отдает ответ. Этот ответ снова сначала попадает в middleware и уже она отдает его наружу.


Если в приложении используются нескольно middleware, то каждая из них добавляет новый уровень вложенности.


Между middleware и обработчиком не обязательно должны передаваться "запрос" и "ответ" в виде web.Request и web.Response. Допускается передавать любые данные.


Таким образом, в middleware можно выделить действия над запросами/ответами, которые будут одинаковыми для всех обработчиков.


Это довольно упрощенное описание, но достаточное для понимания того что будет дальше.


2.1. Простая middleware для json сервиса


Обычно, объявление обработчика запроса в приложении aiohttp.web.Application() выглядит, примерно, так:


from aiohttp import web

async def some_handler(request: web.Request) -> web.Response:
    data = await request.json()
    ...
    text = json.dumps(some_data)
    ...
    return web.Response(text=text, ...)

Для доступа к данным обработчику необходимо "вытащить" из web.Request объект, который был передал в json. Обработать его, сформировать объект с данными для ответа. Закодировать ответ в строку json и отдать "наружу" web.Response (можно отдать и сразу web.json_response()).


2.1.1. Объявление обработчика


Все обработчики нашего приложения должны выполнять подобные шаги. Поэтому, имеет смысл создать middleware, которая возьмет на себя одинаковые действия по подготовке данных и обработке ошибок, а сами обработчики бы стали такими:


from aiohttp import web

async def some_handler(request: web.Request, data: Any) -> Any:
    ...
    return some_data

Каждый из обработчиков имеет два позиционных аргумента. В первый будет передан оригинальный экземпляр web.Request (на всякий случай), во второй — уже готовый объект python, с полученными данными.


В примере, второй аргумент имеет такое объявление: data: Any. Имя у него может быть любым (как и у первого аргумента), а вот в аннотации лучше сразу указать тип объекта, который "ждет" обработчик. Это пожелание справедливо и для возврата.


То есть, в реальном коде, объявление обработчика может быть таким:


from aiohttp import web
from typing import Union, List

async def some_handler(
    request: web.Request, data: Union[str, List[str]]
) -> List[int]:
    ...
    return some_data

2.1.2. Класс SimpleHandler для middleware


Класс SimpleHandler реализует метод для самой middleware и методы, которые впоследствии помогут изменять/дополнять логику работы middleware (ссылка на код класса).


Остановлюсь подробнее только на некоторых.


2.1.2.1. Метод middleware

    @web.middleware
    async def middleware(self, request: web.Request, handler: Callable):
        """ middleware для json-сервиса.
        """
        if not self.is_json_service_handler(request, handler):
            return await handler(request)

        try:
            request_body = await self.get_request_body(request, handler)

        except Exception as error:
            response_body = self.get_error_body(request, error)
            status = 400

        else:
            # Запуск обработчика
            response_body, status = await self.get_response_body_and_status(
                request, handler, request_body
            )

        finally:
            # Самостоятельно делаем дамп объекта python (который находится в
            # response_body) в строку json.
            text, status = await self.get_response_text_and_status(
                request, response_body, status
            )

        return web.Response(
            text=text, status=status, content_type="application/json",
        )

Именно этот метод надо будет добавить в список middlewares в процессе создания приложения.


Например, так:


    ...
    app = web.Application()
    service_handler = SimpleHandler()
    app.middlewares.append(service_handler.middleware)
    ...

2.1.2.2. Метод для получения данных ответа с ошибкой

Так как у нас json сервис, то, желательно, чтобы ошибки во входящих данных (с кодом 400), и внутренние ошибки сервиса (с кодом 500), отдавались в формате json.


Для этого создан метод формирования "тела" для ответа с ошибкой:


    def get_error_body(self, request: web.Request, error: Exception) -> dict:
        """ Отдает словарь с телом ответа с ошибкой.
        """
        return {"error_type": str(type(error)), "error_message": str(error)}

Хочу обратить внимание на то, что этот метод должен отработать без исключений и вернуть объект с описанием ошибки, который можно кодировать в json. Если работа этого метода завершится исключением, то мы не увидим json в теле ответа.


2.1.2.3. Метод запуска обработчика

В текущем классе он очень простой:


    async def run_handler(
        self, request: web.Request, handler: Callable, request_body: Any
    ) -> Any:
        """ Запускает реальный обработчик, и возвращает результат его работы.
        """
        return await handler(request, request_body)

Запуск выделен в отдельный метод для того, чтобы можно было добавить логику до/после выполнения самого обработчика.


2.1.3. Примеры


Имеется такой обработчик:


async def some_handler(request: web.Request, data: dict) -> dict:
    return data

Будем посылать запросы на url этого обработчика.


текст примеров...
2.1.3.1. Ответ с кодом 200

Запрос POST на /some_handler:


{
    "name": "test",
    "age": 25
}

… ожидаемо вернет ответ с кодом 200:


{
    "name": "test",
    "age": 25
}

2.1.3.2. Ответ с кодом 400

Сделаем ошибку в теле запроса.


Запрос POST на /some_handler:


{
    "name": "test", 111111111111
    "age": 25
}

Теперь ответ сервиса выглядит так:


{
    "error_type": "<class 'json.decoder.JSONDecodeError'>",
    "error_message": "Expecting property name enclosed in double quotes: line 2 column 21 (char 22)"
}

2.1.3.3. Ответ с кодом 500

Добавим в код обработчика исключение (эмулируем ошибку сервиса).


async def handler500(request: web.Request, data: dict) -> dict:
    raise Exception("Пример ошибки 500")
    return data

Запрос POST на /handler500:


{
    "name": "test",
    "age": 25
}

в ответ получит такое:


{
    "error_type": "<class 'Exception'>",
    "error_message": "Пример ошибки 500"
}

2.2. middleware для "kwargs-обработчиков"


middleware из предыдущего раздела уже можно успешно использовать.


Но проблема дублирования кода в обработчиках не решена до конца.


Рассмотрим такой пример:


async def some_handler(request: web.Request, data: dict) -> dict:

    storage = request.app["storage"]
    logger = request.app["logger"]
    user_id = request.match_info["user_id"]
    # и т.д. и т.п...

    return data

Так как storage, или logger (или что-то еще), могут быть нужны и в других обработчиках, то везде придется "доставать" их одинаковым образом.


2.2.1. Объявление обработчика


Хотелось бы, чтобы обработчики объявлялись, например, так:


async def some_handler_1(data: dict) -> int:
    # ...
    return some_data

async def some_handler_2(storage: StorageClass, data: List[int]) -> dict:
    # ...
    return some_data

async def some_handler_3(
    data: Union[dict, List[str]], logger: LoggerClass, request: web.Request
) -> str:
    # ...
    return some_data

То есть, чтобы нужные для обработчика сущности объявлялись в его сигнатуре и сразу были бы доступны в коде.


2.2.2. Вспомогательный класс ArgumentsManager


Про нужные для обработчика сущности должна знать middleware, чтобы она смогла "вытащить" небходимые для обработчика и "подсунуть" ему при вызове.


За регистрацию, хранение и "выдачу" таких сущностей отвечает класс ArgumentsManager. Он объявлен в модуле middlewares/utils.py (ссылка на код класса).


Для хранения связи "имя аргумента" — "действие по извлечению значения для аргумента" в этом классе определен простой словарь, где ключем является "имя аргумента", а значением — ссылка на метод, который будет вызван для извлечения "значения аргумента".


Звучит немного запутано, но на самом деле всё просто:


@dataclass
class RawDataForArgument:

    request: web.Request
    request_body: Any
    arg_name: Optional[str] = None

class ArgumentsManager:
    """ Менеджер для аргументов обработчика.

        Связывает имя аргумента с действием, которое надо совершить для
        получения значения аргумента.
    """

    def __init__(self) -> None:

        self.getters: Dict[str, Callable] = {}

    # Тело json запроса ------------------------------------------------------

    def reg_request_body(self, arg_name) -> None:
        """ Регистрация имени аргумента для тела запроса.
        """
        self.getters[arg_name] = self.get_request_body

    def get_request_body(self, raw_data: RawDataForArgument):
        return raw_data.request_body

    # Ключи в request --------------------------------------------------------

    def reg_request_key(self, arg_name) -> None:
        """ Регистрация имени аргумента который хранится в request.
        """
        self.getters[arg_name] = self.get_request_key

    def get_request_key(self, raw_data: RawDataForArgument):
        return raw_data.request[raw_data.arg_name]

    # Ключи в request.app ----------------------------------------------------

    def reg_app_key(self, arg_name) -> None:
        """ Регистрация имени аргумента который хранится в app.
        """
        self.getters[arg_name] = self.get_app_key

    def get_app_key(self, raw_data: RawDataForArgument):
        return raw_data.request.app[raw_data.arg_name]

    # Параметры запроса ------------------------------------------------------

    def reg_match_info_key(self, arg_name) -> None:
        """ Регистрация имени аргумента который приходит в параметрах запроса.
        """
        self.getters[arg_name] = self.get_match_info_key

    def get_match_info_key(self, raw_data: RawDataForArgument):
        return raw_data.request.match_info[raw_data.arg_name]

    # Можно добавить и другие регистраторы...

Регистрация имен аргументов выполняется при создании экземпляра web.Application():



# ...

app = web.Application()

arguments_manager = ArgumentsManager()

# Регистрация имени аргумента обработчика, в который будут передаваться
# данные полученные из json-тела запроса
arguments_manager.reg_request_body("data")

# Регистрация имени аргумента обработчика, в который будет передаваться
# одноименный параметр запроса из словаря request.match_info
arguments_manager.reg_match_info_key("info_id")

# В приложении будем использовать хранилище
# (класс хранилища "взят с потолка" и здесь просто для примера)
app["storage"] = SomeStorageClass(login="user", password="123")
# Регистрация имени аргумента обработчика, в который будет передаваться
# экземпляр хранилища
arguments_manager.reg_app_key("storage")

# ...

Теперь экземпляр ArgumentsManager хранит информацию о возможных аргументах обработчиков. Он передается при создании экземпляра класса для middleware:


...
service_handler = KwargsHandler(arguments_manager=arguments_manager)
app.middlewares.append(service_handler.middleware)
...

Сейчас менеджер очень простой. Можно добавить в него регистрацию сразу нескольких ключей одного вида, правила для разрешения конфликтов имен, и проч… например, и то, что потом можно будет использовать при сборке документации.


2.2.3. Класс KwargsHandler для middleware


Класс KwargsHandler является наследником SimpleHandler и расширяет его возможности тем, что позволяет создавать обработчики согласно требованию п.2.2.1.


В этом классе переопределяется один метод — run_handler, и добавляется еще два — make_handler_kwargs и build_error_message_for_invalid_handler_argument (ссылка на код класса).


2.2.3.1. Метод запуска обработчика

Переопределяется метод родительского класса:


    async def run_handler(
        self, request: web.Request, handler: Callable, request_body: Any
    ) -> Any:
        """ Запускает реальный обработчик, и возвращает результат его работы.

            (Этот метод надо переопределять, если необходима дополнительная
            обработка запроса/ответа/исключений)
        """
        kwargs = self.make_handler_kwargs(request, handler, request_body)

        return await handler(**kwargs)

Как можно заметить, теперь аргументы в обработчик передаются именованными. Таким образом, в обработчиках становится не важен порядок следования аргументов в сигнатуре. Но стали важны сами имена аргументов.


2.2.3.2. Метод формирования словаря с именами аргументов и их значениями

Метод make_handler_kwargs был добавлен в текущий класс. Он реализует заполнение словаря с именами аргументов и их значениями, который будет потом использован при вызове обработчика. Заполнение словаря происходит при помощи уже подготовленного экземпляра ArgumentsManager.


Напомню, что в сигнатурах обработчиков сейчас можно использовать только имена аргументов, которые были зарегистрированы в экземпляре класса ArgumentsManager.


Но у этого требования есть одно исключение. А именно, аргумент с экземпляром web.Request может иметь в сигнатуре обработчика любое имя, но он обязательно должен иметь аннотацию типом web.Request (например, r: web.Request или req: web.Request или request: web.Request). То есть, экземпляр web.Request "зарегистрирован" по умолчанию, и может быть использован в любом обработчике.


И еще одно замечание: все аргументы обработчика должны иметь аннотацию.


Метод build_error_message_for_invalid_handler_argument — просто формирует строку с сообщением об ошибке. Он создан для возможности изменить сообщение на свой вкус.


2.2.4. Примеры


Сигнатуры методов такие:


async def create(
    data: Union[dict, List[dict]], storage: dict,
) -> Union[dict, List[dict]]:
    # ...

async def read(storage: dict, data: str) -> dict:
    # ...

async def info(info_id: int, request: web.Request) -> str:
    # ...

Первые два обслуживают POST запросы, последний — GET (просто, для примера)


текст примеров...
2.2.4.1. Метод /create

Запрос:


[
    {
        "name": "Ivan"
    },
    {
        "name": "Oleg"
    }
]

Ответ:


[
    {
        "id": "5730bab1-9c1b-4b01-9979-9ad640ea5fc1",
        "name": "Ivan"
    },
    {
        "id": "976d821a-e871-41b4-b5a2-2875795d6166",
        "name": "Oleg"
    }
]

2.2.4.2. Метод /read

Запрос:


"5730bab1-9c1b-4b01-9979-9ad640ea5fc1"

Ответ:


{
    "id": "5730bab1-9c1b-4b01-9979-9ad640ea5fc1",
    "name": "Ivan"
}

Примечание: читайте данные с одним из UUID которые получили в предыдущем примере, иначе будет ответ с ошибкой 500PersonNotFound.


2.2.4.3. Метод /info/{info_id}

Запрос GET на /info/123:


"any json"

Ответ:


"info_id=123 and request=<Request GET /info/123 >"

2.3. middleware c оболочками запроса/ответа и валидацией


Иногда, требования для api-сервиса включают в себя стандартизированные оболочки для запросов и ответов.


Например, тело запроса к методу create может быть таким:


{
    "data": [
        {
            "name": "Ivan"
        },
        {
            "name": "Oleg"
        }
    ],
    "id": 11
}

а ответ таким:


{

    "success": true,
    "result": [
        {
            "id": "9738d8b8-69da-40b2-8811-b33652f92f1d",
            "name": "Ivan"
        },
        {
            "id": "df0fdd43-4adc-43cd-ac17-66534529d440",
            "name": "Oleg"
        }
    ],
    "id": 11
}

То есть, данные для запроса в ключе data а от ответа в result.


Имеется ключ id, который в ответе должен иметь такое же значение как и в запросе.


Ключ ответа success является признаком успешности запроса.


А если запрос закончился неудачно, то ответ может быть таким:


Запрос к методу read:


{
    "data":  "ddb0f2b1-0179-44b7-b94d-eb2f3b69292d",
    "id": 3
}

Ответ:


{
    "success": false,
    "result": {
        "error_type": "<class 'handlers.PersonNotFound'>",
        "error_message": "Person whith id=ddb0f2b1-0179-44b7-b94d-eb2f3b69292d not found!"
    },
    "id": 3
}

Уже представленные классы для json middleware позволяют добавить логику работы с оболочками в новый класс для middleware. Надо будет дополнить метод run_handler, и заменить (или дополнить) метод get_error_body.


Таким образом, в обработчики будут "прилетать" только данные, необходимые для их работы (в примере это значение ключа data). Из обработчиков будет возвращаться только положительный результат (значение ключа result). А исключения будет обрабатывать middleware.


Так же, если это необходимо, можно добавить и валидацию данных.


Чтобы "два раза не вставать", я сразу покажу как добавить и оболочки и валидацию. Но сначала необходимо сделать некоторые пояснения по выбранным инструментам.


2.3.1. Класс данных pydantic.BaseModel


pydantic.BaseModel позволяет декларативно объявлять данные.


При создании экземпляра происходит валидация данных по их аннотациям (и не только). Если валидация провалилась — поднимается исключение.


Небольшой пример:


from pydantic import BaseModel
from typing import Union, List

class Info(BaseModel):
    foo: int

class Person(BaseModel):
    name: str
    info: Union[Info, List[Info]]

kwargs = {"name": "Ivan", "info": {"foo": 0}}
person = Person(**kwargs)
assert person.info.foo == 0

kwargs = {"name": "Ivan", "info": [{"foo": 0}, {"foo": 1}]}
person = Person(**kwargs)
assert person.info[1].foo == 1

kwargs = {"name": "Ivan", "info": {"foo": "bar"}}  # <- Ошибка, str не int
person = Person(**kwargs)
# Возникло исключение:
# ...
# pydantic.error_wrappers.ValidationError: 2 validation errors for Person
# info -> foo
#  value is not a valid integer (type=type_error.integer)
# info
#  value is not a valid list (type=type_error.list)

Тут мы видим, что после успешной валидации, поля экземпляра получают значения входящих данных. То есть, был словарь, стал экземпляр класса.


В аннотациях к полям мы можем использовать алиасы из typing.


Если в аннотации к полю присутствует класс-потомок pydantic.BaseModel, то данные "маппятся" и в него (и так с любой вложенностью… хотя, на счет "любой" — не проверял).


Провал валидации сопровождается довольно информативным сообщением об ошибке. В примере мы видим, что на самом деле было две ошибки: info.foo не int, и info не list, что соответствует аннотации и сопоставленному с ней значению.


При использовании pydantic.BaseModel есть нюансы, на которые я хочу обратить внимание.


2.3.1.1. Строгие типы

Если в любом из приведенных выше примеров заменить целое на строку, содержащую только цифры, то валидация всё равно закончится успешно:


kwargs = {"name": "Ivan", "info": {"foo": "0"}}
person = Person(**kwargs)
assert person.info.foo == 0

То есть, имеем неявное приведение типов. И такое встречается не только с str->int (более подробно про типы pydantic см. в документации).


Приведение типов, в определенных ситуациях, может оказаться полезным, например строка с UUID -> UUID. Но, если приведение некоторых типов недопустимо, то в аннотациях надо использовать типы, наименование у которых начинается со Strict.... Например, pydantic.StrictInt, pydantic.StrictStr, и т.п...


2.3.1.2. Строгая сигнатура при создании экземпляра

Если, для определенных выше классов, попробовать выполнить такой пример:


kwargs = {"name": "Ivan", "info": {"foo": 0}, "bar": "BAR"}
person = Person(**kwargs)

То создание экземпляра пройдет без ошибок.


Это тоже может оказаться не тем, что ожидаешь по умолчанию.


Для строгой проверки аргументов, при создании экземпляра, необходимо переопределить базовый класс:


from pydantic import BaseModel, Extra, StrictInt, StrictStr
from typing import Union, List

class BaseApi(BaseModel):
    class Config:
        # Следует ли игнорировать (ignore), разрешать (allow) или
        # запрещать (forbid) дополнительные атрибуты во время инициализации
        # модели, подробнее:
        # https://pydantic-docs.helpmanual.io/usage/model_config/
        extra = Extra.forbid

class Info(BaseApi):
    foo: StrictInt

class Person(BaseApi):
    name: StrictStr
    info: Union[Info, List[Info]]

kwargs = {"name": "Ivan", "info": {"foo": 0}, "bar": "BAR"}
person = Person(**kwargs)
# ...
# pydantic.error_wrappers.ValidationError: 1 validation error for Person
# bar
#   extra fields not permitted (type=value_error.extra)

Теперь — все нормально, валидация провалилась.


2.3.2. Декоратор valdec.validate


Декоратор valdec.validate позволяет валидировать аргументы и/или возвращаемое значение функции или метода.


Можно валидировать только те аргументы, для которых указана аннотация.


Если у возврата нет аннотации, то считается что функция должна вернуть None (имеет аннотацию -> None:).


Определен декоратор как для обычных функций/методов:


from valdec.decorators import validate

@validate  # Валидируем все аргументы с аннотациями, и возврат
def foo(i: int, s: str) -> int:
    return i

@validate("i", "s")  # Валидируем только "i" и "s"
def bar(i: int, s: str) -> int:
    return i

… так и для асинхронных.


# Импортируем асинхронный вариант
from valdec.decorators import async_validate as validate

@validate("s", "return", exclude=True)  # Валидируем только "i"
async def foo(i: int, s: str) -> int:
    return int(i)

@validate("return")  # Валидируем только возврат
async def bar(i: int, s: str) -> int:
    return int(i)

2.3.2.1. Функции-валидаторы

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


Сигнатура функции-валидатора:


def validator(
    annotations: Dict[str, Any],
    values: Dict[str, Any],
    is_replace: bool,
    extra: dict
) -> Optional[Dict[str, Any]]:

Аргументы:


  • annotations — Словарь, который содержит имена аргументов и их аннотации.
  • values — Словарь, который содержит имена аргументов и их значения.
  • is_replace — управляет тем, что возвращает функция-валидатор, а именно — возвращать отвалидированные значения или нет.
    • Если True, то функция должна вернуть словарь с именами отвалидированных аргументов и их значениями после валидации. Таким образом, например, если у аргумента была аннотация с наследником BaseModel и данные для него поступили в виде словаря, то они будут заменены на экземпляр BaseModel, и в декорируемой функции к ним можно будет обращаться "через точку".
    • Если параметр равен False, то функция вернет None, а декорируемая функция получит оригинальные данные (то есть, например, словарь так и останется словарем, а не станет экземпляром BaseModel).
  • extra — Словарь с дополнительными параметрами.

По умолчанию, в декораторе validate используется функция-валидатор на основе pydantic.BaseModel.


В ней происходит следующее:


  • На основании словаря с именами аргументов и их аннотаций создается класс данных (потомок pydantic.BaseModel)
  • Создается экземпляр этого класса в который передается словарь с именами и значениями. В этот момент и происходит валидация.
  • Возвращает функция аргументы после валидации (которые уже буду содержать значения из созданного экземпляра), или ничего не возвращает, зависит от аргумента is_replace.

Вызов функции происходит один раз для всех аргументов, и второй раз, отдельно, для возврата. Конечно, если есть что валидировать, как в первом, так и во втором случае.


Функция-валидатор может быть реализована на основе любого валидирующего класса (в репозитарии valdec есть пример реализации на ValidatedDC). Но необходимо учесть следующее: далее в статье, я буду использовать потомков pydantic.BaseModel в аннотациях аргументов у обработчиков. Соответственно, при другом валидирующем классе, в аннотациях необходимо будет указывать потомков этого "другого" класса.


2.3.2.2. Настройка декоратора

По умолчанию, декоратор "подменяет" исходные данные на данные экземпляра валидирующего класса:


from typing import List, Optional

from pydantic import BaseModel, StrictInt, StrictStr
from valdec.decorators import validate

class Profile(BaseModel):
    age: StrictInt
    city: StrictStr

class Student(BaseModel):
    name: StrictStr
    profile: Profile

@validate("group")
def func(group: Optional[List[Student]] = None):
    for student in group:
        assert isinstance(student, Student)
        assert isinstance(student.name, str)
        assert isinstance(student.profile.age, int)

data = [
    {"name": "Peter", "profile": {"age": 22, "city": "Samara"}},
    {"name": "Elena", "profile": {"age": 20, "city": "Kazan"}},
]

func(data)

Обратите внимание на assert'ы.


Это работает и для возврата:


@validate  # Валидируем всё
def func(group: Optional[List[Student]] = None, i: int) -> List[Student]:
    #...
    return [
        {"name": "Peter", "profile": {"age": 22, "city": "Samara"}},
        {"name": "Elena", "profile": {"age": 20, "city": "Kazan"}},
    ]

Здесь, несмотря на то, что в return явно указан список словарей, функция вернет список экземпляров Student (подмену выполнит декоратор).


Но… Нам не всегда надо именно такое поведение. Иногда, бывает полезно отвалидировать, а данные не подменять (например, если речь о входящих данных, чтобы сразу отдать их в БД). И этого можно добиться изменив настройки декоратора:


from valdec.data_classes import Settings
from valdec.decorators import validate as _validate
from valdec.validator_pydantic import validator

custom_settings = Settings(
    validator=validator,     # Функция-валидатор.
    is_replace_args=False,   # Делать ли подмену в аргументах
    is_replace_result=False, # Делать ли подмену в результате
    extra={}                 # Дополнительные параметры, которые будут
                             # передаваться в функцию-валидатор
)
# Определяем новый декоратор
def validate_without_replacement(*args, **kwargs):
    kwargs["settings"] = custom_settings
    return _validate(*args, **kwargs)

# Используем
@validate_without_replacement
def func(group: Optional[List[Student]] = None, i: int) -> List[Student]:
    #...
    return [
        {"name": "Peter", "profile": {"age": 22, "city": "Samara"}},
        {"name": "Elena", "profile": {"age": 20, "city": "Kazan"}},
    ]

И теперь func вернет список словарей, так как is_replace_result=False. И получит тоже список словарей, так как is_replace_args=False.


Но сама валидация данных будет работать как и раньше, не будет лишь подмены.


Есть один нюанс — линтер, иногда, может "ругаться" на различия в типах. Да, это минус. Но всегда лучше иметь выбор, чем его не иметь.


Может возникнуть вопрос — что будет, если декоратор получит, допустим, не словарь, а уже готовый экземпляр класса? Ответ — будет выполнена обычная проверка экземпляра на соответствие типу.


Как можно заметить, в настройках указывается и функция-валидатор, и если вы захотите использовать свою — именно там нужно ее подставить.


2.3.2.3. Еще раз про приведение типов

Рассмотрим такой пример применения декоратора:


from valdec.decorators import validate

@validate
def foo(i: int):
    assert isinstance(i, int)

foo("1")

Мы вызываем функцию и передаем ей строку. Но валидация прошла успешно, и в функцию прилетело целое.


Как я уже говорил, по умолчанию, в декораторе validate, используется функция-валидатор на основе pydantic.BaseModel. В п.2.3.1.1. можно еще раз почитать про неявное приведение типов в этом классе.


В нашем же примере, для того чтобы получить желаемое поведение (ошибку валидации), необходимо сделать так:


from valdec.decorators import validate
from pydantic import StrictInt

@validate
def foo(i: StrictInt):
    pass

foo("1")
# ...
# valdec.errors.ValidationArgumentsError: Validation error
# <class 'valdec.errors.ValidationError'>: 1 validation error for
# argument with the name of:
# i
#  value is not a valid integer (type=type_error.integer).

Вывод такой: Используя декоратор на основе валидирующего класса, аннотации к аргументам функции надо писать по правилам этого класса.


Не забывайте про это.


2.3.2.4. Исключения

  • valdec.errors.ValidationArgumentsError — "поднимается" если валидация аргументов функции потерпела неудачу
  • valdec.errors.ValidationReturnError — если не прошел валидацию возврат

Само сообщение с описанием ошибки берется из валидирующего класса. В нашем примере это сообщение об ошибке от pydantic.BaseModel.


2.3.3. Базовый класс данных


Как я уже говорил, в этой статье используем классы-наследники от pydantic.BaseModel.


Cначала обязательно определим базовый класс данных:


data_classes/base.py


from pydantic import BaseModel, Extra

class BaseApi(BaseModel):
    """ Базовый класс данных для api.
    """
    class Config:
        extra = Extra.forbid

2.3.4. Объявление обработчика


Класс для middleware, над созданием которого мы сейчас работаем, позволит объявлять обработчики, например, так:


from typing import List, Union
from valdec.decorators import async_validate as validate
from data_classes.person import PersonCreate, PersonInfo

@validate("data", "return")
async def create(
    data: Union[PersonCreate, List[PersonCreate]], storage: dict,
) -> Union[PersonInfo, List[PersonInfo]]:
    # ...
    return result

Что здесь добавилось (по сравнению с обработчиками из прошлых глав):


  • декоратор validate валидирует поступившие данные и ответ, и "подменяет" их на экземпляры валидирующих классов
  • в аннотациях у данных указаны уже конкретные классы.

Про оболочки запросов/ответов обработчик ничего не знает, ему это и не надо.


Позволю себе небольшую ремарку: если в аргументах у обработчика нет экземпляра web.Request(), то он является обычной функцией, которую можно использовать не только в wep.Aplication(). На самом деле, даже если он там есть, эту функцию по-прежнему можно будет использовать в приложениях другого типа, если обеспечить совместимый с web.Request() экземпляр данных.


Соответственно, классы данных для этого обработчика могут быть такими:


data_classes/person.py


from uuid import UUID
from pydantic import Field, StrictStr
from data_classes.base import BaseApi

class PersonCreate(BaseApi):
    """ Данные для создания персоны.
    """
    name: StrictStr = Field(description="Имя.", example="Oleg")

class PersonInfo(BaseApi):
    """ Информация о персоне.
    """
    id: UUID = Field(description="Идентификатор.")
    name: StrictStr = Field(description="Имя.")

2.3.5. Классы данных для оболочек


В самом начале п.2.3. были обозначены тебования к оболочкам запроса и ответа.


Для их выполнения создадим классы данных.


data_classes/wraps.py


from typing import Any, Optional
from pydantic import Field, StrictInt
from data_classes.base import BaseApi

_ID_DESCRIPTION = "Идентификатор запроса к сервису."

class WrapRequest(BaseApi):
    """ Запрос.
    """
    data: Any = Field(description="Параметры запроса.", default=None)
    id: Optional[StrictInt] = Field(description=_ID_DESCRIPTION)

class WrapResponse(BaseApi):
    """ Ответ.
    """
    success: bool = Field(description="Статус ответа.", default=True)
    result: Any = Field(description="Результат ответа.")
    id: Optional[StrictInt] = Field(description=_ID_DESCRIPTION)

Эти классы будут использоваться в классе для middleware при реализации логики оболочек.


2.3.6. Класс WrapsKwargsHandler для middleware


Класс WrapsKwargsHandler является наследником KwargsHandler и расширяет его возможности тем, что позволяет использовать оболочки для данных запросов и ответов и их валидацию (ссылка на код класса).


В этом классе переопределяются два метода — run_handler и get_error_body.


2.3.6.1. Метод запуска обработчика

Переопределяется метод родительского класса:


async def run_handler(
        self, request: web.Request, handler: Callable, request_body: Any
    ) -> dict:

        id_ = None

        try:
            # Проведем валидацию оболочки запроса
            wrap_request = WrapRequest(**request_body)

        except Exception as error:
            message = f"{type(error).__name__} - {error}"
            raise InputDataValidationError(message)

        # Запомним поле id для ответов
        id_ = wrap_request.id
        request[KEY_NAME_FOR_ID] = id_

        try:
            result = await super().run_handler(
                request, handler, wrap_request.data
            )
        except ValidationArgumentsError as error:
            message = f"{type(error).__name__} - {error}"
            raise InputDataValidationError(message)

        # Проведем валидацию оболочки ответа
        wrap_response = WrapResponse(success=True, result=result, id=id_)

        return wrap_response.dict()

Сначала мы проверим оболочку запроса. Исключение InputDataValidationError поднимется в следующих случаях:


  • если в теле запроса не словарь (пусть даже пустой)
  • если есть поля с ключами отличными от data и id
  • если есть ключ id но его значение не StrictInt и не None

Если в запросе нет ключа id, то wrap_request.id получит значение None. Ключ data может иметь любое значение и валидироваться не будет. Так же, его может вообще не быть во входящих данных, тогда wrap_request.data получит значение None.


Затем мы запоминаем wrap_request.id в request. Это необходимо для формирования ответа с ошибкой на текущий запрос (если она произойдет).


После этого вызывается обработчик, но для его входящих данных передается только wrap_request.data (напомню, что во wrap_request.data сейчас объект python в том виде, как он был получен из json). При этом, исключение InputDataValidationError поднимается если получено исключение valdec.errors.ValidationArgumentsError.


Если обработчик отработал нормально, и был получен результат его работы, то создаем экземпляр класса оболочки ответа WrapResponse в варианте для успешного ответа.


Все просто, но хотел бы обратить внимание на такой момент. Можно было бы обойтись без создания wrap_response, а сразу сформировать словарь (как это и будет сделано для ответа с ошибкой). Но, в случае успешного ответа мы не знаем что пришло в ответе от обработчика, это может быть, например, как список словарей, так и список экземпляров BaseApi. А на выходе из метода мы должны гарантированно отдать объект, готовый для кодирования в json. Поэтому, мы "заворачиваем" любые данные с результом во WrapResponse.result и уже из wrap_response получаем окончательный ответ для метода при помощи wrap_response.dict() (ссылка на документацию).


2.3.6.2. Метод для получения данных ответа с ошибкой

Заменяется метод родительского класса:


def get_error_body(self, request: web.Request, error: Exception) -> dict:
        """ Формирует и отдает словарь с телом ответа с ошибкой.
        """
        result = dict(error_type=str(type(error)), error_message=str(error))
        # Так как мы знаем какая у нас оболочка ответа, сразу сделаем словарь
        # с аналогичной "схемой"
        response = dict(
            # Для поля id используется сохраненное в request значение.
            success=False, result=result, id=request.get(KEY_NAME_FOR_ID)
        )
        return response

Здесь можно было бы применить и наследование (вызвать super() для получения result), но для наглядности я оставил так. Вы можете сделать как сочтете нужным.


2.3.7. Примеры


Сигнатуры методов такие:



@validate("data", "return")
async def create(
    data: Union[PersonCreate, List[PersonCreate]], storage: dict,
) -> Union[PersonInfo, List[PersonInfo]]:
    # ...

@validate("data", "return")
async def read(storage: dict, req: web.Request, data: UUID) -> PersonInfo:
    # ...

@validate("info_id")
async def info(info_id: int, request: web.Request) -> Any:
    return f"info_id={info_id} and request={request}"

Первые два обслуживают POST запросы, последний — GET (просто, для примера)


текст примеров...
2.3.7.1. Метод /create

  • Запрос №1:

{
    "data": [
        {
            "name": "Ivan"
        },
        {
            "name": "Oleg"
        }
    ],
    "id": 1
}

Ответ:


{
    "success": true,
    "result": [
        {
            "id": "af908a90-9157-4231-89f6-560eb6a8c4c0",
            "name": "Ivan"
        },
        {
            "id": "f7d554a0-1be9-4a65-bfc2-b89dbf70bb3c",
            "name": "Oleg"
        }
    ],
    "id": 1
}

  • Запрос №2:

{
    "data": {
        "name": "Eliza"
    },
    "id": 2
}

Ответ:


{
    "success": true,
    "result": {
        "id": "f3a45a19-acd0-4939-8e0c-e10743ff8e55",
        "name": "Eliza"
    },
    "id": 2
}

  • Запрос №3:

Попробуем передать в data невалидное значение


{
    "data": 123,
    "id": 3
}

Ответ:


{
    "success": false,
    "result": {
        "error_type": "<class 'middlewares.exceptions.InputDataValidationError'>",
        "error_message": "ValidationArgumentsError - Validation error <class 'valdec.errors.ValidationError'>: 2 validation errors for argument with the name of:\ndata\n  value is not a valid dict (type=type_error.dict)\ndata\n  value is not a valid list (type=type_error.list)."
    },
    "id": 3
}

2.3.7.2. Метод /read

  • Запрос №1:

{
    "data": "f3a45a19-acd0-4939-8e0c-e10743ff8e55",
    "id": 4
}

Ответ:


{
    "success": true,
    "result": {
        "id": "f3a45a19-acd0-4939-8e0c-e10743ff8e55",
        "name": "Eliza"
    },
    "id": 4

  • Запрос №2:

Попробуем сделать ошибку в оболочке.


{
    "some_key": "f3a45a19-acd0-4939-8e0c-e10743ff8e55",
    "id": 5
}

Ответ:


{
    "success": false,
    "result": {
        "error_type": "<class 'middlewares.exceptions.InputDataValidationError'>",
        "error_message": "ValidationError - 1 validation error for WrapRequest\nsome_key\n  extra fields not permitted (type=value_error.extra)"
    },
    "id": null
}

2.3.7.3. Метод /info/{info_id}

  • Запрос GET на /info/123:

{}

Ответ:


{
    "success": true,
    "result": "info_id=123 and request=<Request GET /info/123 >",
    "id": null
}

3. О нереализованной документации


У обработчиков, которые используются с классом WrapsKwargsHandler, есть всё, чтобы автоматически собрать документацию. К ним более не надо ничего добавлять. Так как классы pydantic.BaseModel позволяют получать json-schema, то остается только сделать скрипт сборки документации (если кратко, то надо: перед запуском приложения пройтись по всем обработчикам и у каждого заменить докстринг на swagger-описание, построенное на основе уже имеющегося докстринга и json-схем входящих данных и возврата).


И я эту документацию собираю. Но не стал рассказывать про это в статье. Причина в том, что я не нашел библиотеки для swagger и aiohttp, которая бы работала полностью как надо (или я не нашел способа заставить работать как надо).


Например, библиотека aiohttp-swagger некорректно отображает аргумент (в областях с примерами), если в аннотации есть алиас Union.


Библиотека aiohttp-swagger3, напротив, все прекрасно показывает, но не работает если в приложении есть sub_app.


Если кто-то знает как решить эти проблемы, или, возможно, кто-то знает библиотеку, которая работает стабильно — буду очень благодарен за комментарий.


4. Заключение


В итоге у нас имеются три класса для json middleware с разными возможностями. Любой из них можно изменить под свои нужды. Или создать на их основе новый.


Можно создавать любые оболочки для содержимого запросов и ответов. Так же, можно гибко настраивать валидацию, и применять ее только там, где она действительно необходима.


Не сомневаюсь в том, что примеры которые я предложил, можно реализовать и по другому. Но надеюсь, что мои решения, если и не пригодятся полностью, то поспособствуют нахождению иных, более подходящих.


Спасибо за уделенное время. Буду рад замечаниям, и уточнениям.


При публикации статьи использовал MarkConv

Комментарии 4

    0

    А в чем принципиальная разница от FastApi?

      0
      Наверное, в чем-то есть :)
      Я не работал с FastApi.
        0

        Тогда очень советую обратить внимание: https://fastapi.tiangolo.com/
        В принципе, ваш велосипед (простите) очень похож во многих вещах, но в FastApi гораздо больше фич.

          0
          Не сомневаюсь, что FastApi прекрасный фреймворк, и в нём много всяких фич.
          Но в заголовке — aiohttp :)

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое