Json api сервис на aiohttp: middleware и валидация
- понедельник, 1 марта 2021 г. в 00:28:48
В этой статье я опишу один из подходов для создания json api сервиса с валидацией данных.
Сервис будет реализован на aiohttp. Это современный, постоянно развивающийся фреймворк на языке python, использующий asyncio
.
Об аннотациях:
Появление аннотаций в python
позволило сделать код более понятным. Так же, аннотации открывают некоторые дополнительные возможности. Именно аннотации играют ключевую роль при валидации данных у обработчиков api-методов в этой статье.
Используемые библиотеки:
Оглавление:
- 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
middleware
в aiohttp.web.Application()
является оболочкой для обработчиков запросов.
Если в приложении используется middleware
, то поступивший запрос сначала попадает в неё, и только потом передается в обработчик. Обработчик формирует и отдает ответ. Этот ответ снова сначала попадает в middleware
и уже она отдает его наружу.
Если в приложении используются нескольно middleware
, то каждая из них добавляет новый уровень вложенности.
Между middleware
и обработчиком не обязательно должны передаваться "запрос" и "ответ" в виде web.Request
и web.Response
. Допускается передавать любые данные.
Таким образом, в middleware
можно выделить действия над запросами/ответами, которые будут одинаковыми для всех обработчиков.
Это довольно упрощенное описание, но достаточное для понимания того что будет дальше.
Обычно, объявление обработчика запроса в приложении 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()
).
Все обработчики нашего приложения должны выполнять подобные шаги. Поэтому, имеет смысл создать 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
SimpleHandler
для middleware Класс SimpleHandler
реализует метод для самой middleware
и методы, которые впоследствии помогут изменять/дополнять логику работы middleware
(ссылка на код класса).
Остановлюсь подробнее только на некоторых.
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)
...
Так как у нас 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 в теле ответа.
В текущем классе он очень простой:
async def run_handler(
self, request: web.Request, handler: Callable, request_body: Any
) -> Any:
""" Запускает реальный обработчик, и возвращает результат его работы.
"""
return await handler(request, request_body)
Запуск выделен в отдельный метод для того, чтобы можно было добавить логику до/после выполнения самого обработчика.
Имеется такой обработчик:
async def some_handler(request: web.Request, data: dict) -> dict:
return data
Будем посылать запросы на url этого обработчика.
Запрос POST
на /some_handler
:
{
"name": "test",
"age": 25
}
… ожидаемо вернет ответ с кодом 200:
{
"name": "test",
"age": 25
}
Сделаем ошибку в теле запроса.
Запрос 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)"
}
Добавим в код обработчика исключение (эмулируем ошибку сервиса).
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"
}
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
(или что-то еще), могут быть нужны и в других обработчиках, то везде придется "доставать" их одинаковым образом.
Хотелось бы, чтобы обработчики объявлялись, например, так:
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
То есть, чтобы нужные для обработчика сущности объявлялись в его сигнатуре и сразу были бы доступны в коде.
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)
...
Сейчас менеджер очень простой. Можно добавить в него регистрацию сразу нескольких ключей одного вида, правила для разрешения конфликтов имен, и проч… например, и то, что потом можно будет использовать при сборке документации.
KwargsHandler
для middleware Класс KwargsHandler
является наследником SimpleHandler
и расширяет его возможности тем, что позволяет создавать обработчики согласно требованию п.2.2.1.
В этом классе переопределяется один метод — run_handler
, и добавляется еще два — make_handler_kwargs
и build_error_message_for_invalid_handler_argument
(ссылка на код класса).
Переопределяется метод родительского класса:
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)
Как можно заметить, теперь аргументы в обработчик передаются именованными. Таким образом, в обработчиках становится не важен порядок следования аргументов в сигнатуре. Но стали важны сами имена аргументов.
Метод 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
— просто формирует строку с сообщением об ошибке. Он создан для возможности изменить сообщение на свой вкус.
Сигнатуры методов такие:
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
(просто, для примера)
Запрос:
[
{
"name": "Ivan"
},
{
"name": "Oleg"
}
]
Ответ:
[
{
"id": "5730bab1-9c1b-4b01-9979-9ad640ea5fc1",
"name": "Ivan"
},
{
"id": "976d821a-e871-41b4-b5a2-2875795d6166",
"name": "Oleg"
}
]
Запрос:
"5730bab1-9c1b-4b01-9979-9ad640ea5fc1"
Ответ:
{
"id": "5730bab1-9c1b-4b01-9979-9ad640ea5fc1",
"name": "Ivan"
}
Примечание: читайте данные с одним из UUID
которые получили в предыдущем примере, иначе будет ответ с ошибкой 500
— PersonNotFound
.
Запрос GET
на /info/123
:
"any json"
Ответ:
"info_id=123 and request=<Request GET /info/123 >"
Иногда, требования для 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
.
Так же, если это необходимо, можно добавить и валидацию данных.
Чтобы "два раза не вставать", я сразу покажу как добавить и оболочки и валидацию. Но сначала необходимо сделать некоторые пояснения по выбранным инструментам.
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
есть нюансы, на которые я хочу обратить внимание.
Если в любом из приведенных выше примеров заменить целое на строку, содержащую только цифры, то валидация всё равно закончится успешно:
kwargs = {"name": "Ivan", "info": {"foo": "0"}}
person = Person(**kwargs)
assert person.info.foo == 0
То есть, имеем неявное приведение типов. И такое встречается не только с str->int
(более подробно про типы pydantic
см. в документации).
Приведение типов, в определенных ситуациях, может оказаться полезным, например строка с UUID
-> UUID
. Но, если приведение некоторых типов недопустимо, то в аннотациях надо использовать типы, наименование у которых начинается со Strict...
. Например, pydantic.StrictInt
, pydantic.StrictStr
, и т.п...
Если, для определенных выше классов, попробовать выполнить такой пример:
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)
Теперь — все нормально, валидация провалилась.
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)
Декоратор получает данные об аргументах/возврате функции, и передает их в функцию-валидатор (это сильно упрощенно, но по сути так), которая и производит, непосредственно, валидацию.
Сигнатура функции-валидатора:
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
в аннотациях аргументов у обработчиков. Соответственно, при другом валидирующем классе, в аннотациях необходимо будет указывать потомков этого "другого" класса.
По умолчанию, декоратор "подменяет" исходные данные на данные экземпляра валидирующего класса:
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
.
Но сама валидация данных будет работать как и раньше, не будет лишь подмены.
Есть один нюанс — линтер, иногда, может "ругаться" на различия в типах. Да, это минус. Но всегда лучше иметь выбор, чем его не иметь.
Может возникнуть вопрос — что будет, если декоратор получит, допустим, не словарь, а уже готовый экземпляр класса? Ответ — будет выполнена обычная проверка экземпляра на соответствие типу.
Как можно заметить, в настройках указывается и функция-валидатор, и если вы захотите использовать свою — именно там нужно ее подставить.
Рассмотрим такой пример применения декоратора:
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).
Вывод такой: Используя декоратор на основе валидирующего класса, аннотации к аргументам функции надо писать по правилам этого класса.
Не забывайте про это.
valdec.errors.ValidationArgumentsError
— "поднимается" если валидация аргументов функции потерпела неудачуvaldec.errors.ValidationReturnError
— если не прошел валидацию возвратСамо сообщение с описанием ошибки берется из валидирующего класса. В нашем примере это сообщение об ошибке от pydantic.BaseModel
.
Как я уже говорил, в этой статье используем классы-наследники от pydantic.BaseModel
.
Cначала обязательно определим базовый класс данных:
data_classes/base.py
from pydantic import BaseModel, Extra
class BaseApi(BaseModel):
""" Базовый класс данных для api.
"""
class Config:
extra = Extra.forbid
Класс для 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. были обозначены тебования к оболочкам запроса и ответа.
Для их выполнения создадим классы данных.
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
при реализации логики оболочек.
WrapsKwargsHandler
для middleware Класс WrapsKwargsHandler
является наследником KwargsHandler
и расширяет его возможности тем, что позволяет использовать оболочки для данных запросов и ответов и их валидацию (ссылка на код класса).
В этом классе переопределяются два метода — run_handler
и get_error_body
.
Переопределяется метод родительского класса:
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()
(ссылка на документацию).
Заменяется метод родительского класса:
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
), но для наглядности я оставил так. Вы можете сделать как сочтете нужным.
Сигнатуры методов такие:
@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 (просто, для примера)
{
"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
}
{
"data": {
"name": "Eliza"
},
"id": 2
}
Ответ:
{
"success": true,
"result": {
"id": "f3a45a19-acd0-4939-8e0c-e10743ff8e55",
"name": "Eliza"
},
"id": 2
}
Попробуем передать в 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
}
{
"data": "f3a45a19-acd0-4939-8e0c-e10743ff8e55",
"id": 4
}
Ответ:
{
"success": true,
"result": {
"id": "f3a45a19-acd0-4939-8e0c-e10743ff8e55",
"name": "Eliza"
},
"id": 4
Попробуем сделать ошибку в оболочке.
{
"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
}
GET
на /info/123
:{}
Ответ:
{
"success": true,
"result": "info_id=123 and request=<Request GET /info/123 >",
"id": null
}
У обработчиков, которые используются с классом WrapsKwargsHandler
, есть всё, чтобы автоматически собрать документацию. К ним более не надо ничего добавлять. Так как классы pydantic.BaseModel
позволяют получать json-schema, то остается только сделать скрипт сборки документации (если кратко, то надо: перед запуском приложения пройтись по всем обработчикам и у каждого заменить докстринг на swagger-описание, построенное на основе уже имеющегося докстринга и json-схем входящих данных и возврата).
И я эту документацию собираю. Но не стал рассказывать про это в статье. Причина в том, что я не нашел библиотеки для swagger
и aiohttp
, которая бы работала полностью как надо (или я не нашел способа заставить работать как надо).
Например, библиотека aiohttp-swagger
некорректно отображает аргумент (в областях с примерами), если в аннотации есть алиас Union
.
Библиотека aiohttp-swagger3
, напротив, все прекрасно показывает, но не работает если в приложении есть sub_app
.
Если кто-то знает как решить эти проблемы, или, возможно, кто-то знает библиотеку, которая работает стабильно — буду очень благодарен за комментарий.
В итоге у нас имеются три класса для json middleware с разными возможностями. Любой из них можно изменить под свои нужды. Или создать на их основе новый.
Можно создавать любые оболочки для содержимого запросов и ответов. Так же, можно гибко настраивать валидацию, и применять ее только там, где она действительно необходима.
Не сомневаюсь в том, что примеры которые я предложил, можно реализовать и по другому. Но надеюсь, что мои решения, если и не пригодятся полностью, то поспособствуют нахождению иных, более подходящих.
Спасибо за уделенное время. Буду рад замечаниям, и уточнениям.
При публикации статьи использовал MarkConv