Привет! Хочу представить вам свою библиотеку – unihttp.
Уверен, что все сталкивались с необходимостью работы с какими-либо API, но что делать, если у этого сервиса (внешнего или внутреннего) отсутствует библиотека, позволяющая лаконично вызывать нужные методы?
Если это единичный вызов – можно обернуть в функцию и используя голые requests / aiohttp отправлять запросы. В случае, если у вас идет тесное взаимодействие с этим API, или у вас приложение в принципе построено для работы с ним, то ваш код превращается в огромную простыню функций или вызовов requests / aiohttp и последующего трансформирования ответа в нужный тип.
Рано или поздно, вы приходите к мысли о том, что хорошей идеей было бы вынести работу с этим API в библиотеку. Появляется новый вопрос: как ее делать?
В основном, мы решаем следующие вопросы в процессе размышления над библиотекой:
Каким образом мы должны работать с ответами API?
Как обрабатывать ошибки сервиса?
Как выделить авторизацию и прокидывать собственный токен?
Внешний вид библиотеки (ее интерфейс)
Не самые редкие случаи, могут требовать решения и иных вопросов, например: пре/пост обработка ответов API (т.е. использование middleware) или необходимость гибкой работы с неймингом в API, например: преобразовывать user_id в userId и т.д.
Эти вопросы можно решить с помощью моей библиотеки unihttp, давайте рассмотрим, что она умеет и как этим пользоваться. Уверен, у многих уже появилась рабочая необходимость для работы с API мессенджера Max, поэтому выберем его для примера. Начнем с самого базового – API-методы.
Сделаем метод получения профиля бота, и для начала опишем получаемые нами типы:
from dataclasses import dataclass @dataclass class BotCommand: name: str description: str | None = None @dataclass class Bot: user_id: int first_name: str is_bot: bool last_activity_time: int last_name: str | None = None username: str | None = None description: str | None = None avatar_url: str | None = None full_avatar_url: str | None = None commands: list[BotCommand] | None = None
Затем нам необходимо создать класс API-запроса:
from unihttp.method import BaseMethod @dataclass class GetMe(BaseMethod[Bot]): __method__ = "GET" __url__ = "/me"
__method__ – HTTP-метод запроса: GET / POST / PATCH и т.д.
__url__ – путь к эндпоинту без учета базового пути (рабо��ает в том числе без / в начале)
И наконец, создадим класс самого клиента:
from niquests import AsyncSession from unihttp.bind_method import bind_method from unihttp.clients.niquests import NiquestsAsyncClient from unihttp.serializers.adaptix import DEFAULT_RETORT class MaxClient(NiquestsAsyncClient): def __init__(self, token: str) -> None: super().__init__( base_url="https://platform-api.max.ru", request_dumper=DEFAULT_RETORT, response_loader=DEFAULT_RETORT, session=AsyncSession( headers={ "Authorization": token } ) ) get_me = bind_method(GetMe)
Что мы здесь делаем:
импортируем основу клиента –
NiquestsAsyncClientинициализируем клиент:
base_url– базовый урл APIrequest_dumper– используется для дампа запросаresponse_loader– используется для десериализации ответовsession– сюда мы передаемAsyncSessionс хедерами, в которых у нас содержится авторизационный хедер
используем
bind_methodдля привязки метода к клиенту
Часть с request_dumper и response_loader мы рассмотрим немного позже.
Итого, инициализация клиента и вызов метода, у нас выглядит следующим образом:
async def main(): TOKEN = "Ваш токен от бота" client = MaxClient(token=TOKEN) me = await client.get_me() pprint(me)
После запуска этого кода, мы получаем следующее (реальные данные убраны):
Bot( user_id=1, first_name='имя бта', is_bot=True, last_activity_time=1, last_name=None, username='bot', description='описание бота', avatar_url='https://i.oneme.ru/i?r=12345', full_avatar_url='https://i.oneme.ru/i?r=12345', commands=None )
Давайте рассмотрим немного более сложный запрос: закрепление сообщения в групповом чате.
from unihttp.omitted import Omittable, Omitted from unihttp.markers import Path, Body @dataclass class QueryObject: success: bool message: str | None = None @dataclass class PinMessage(BaseMethod[QueryObject]): __url__ = "/chats/{chat_id}/pin" __method__ = "PUT" chat_id: Path[int] message_id: Body[str] notify: Omittable[bool] = Omitted()
Здесь у нас появились два метода параметров:
Path-параметр
chat_idBody-параметры:
message_id,notify
notify является Omittable параметром, у него нет дефолтного значения в самом методе, и он не будет передан в тело запроса.
Вместо того, чтобы вручную собирать словари для заголовков или заниматься конкатенацией строк для URL, unihttp использует систему специальных маркеров. Это позволяет превратить сигнатуру метода в прозрачное описание HTTP-запроса.
В библиотеке есть 6 маркеров, которые закрывают 100% потребностей:
Path– подставляет параметр в__url__Query– автоматически сериализует параметры в query-stringHeader– добавляет хедеры к запросуBody– параметры, которые должны быть отправлены в JSON-формате в теле запросаForm– параметры, которые используют стандартную URL-кодировку для передачи полей в теле сообщенияFile– используется для отправки файлов
Обработка ошибок
Давайте теперь сделаем обработку ошибок в клиенте.
В базовых клиентах библиотеки BaseSyncClient и BaseAsyncClient есть два метода: validate_response и handle_error.
validate_response – идея этого метода заключается в том, что у каких-то API могут быть специфичные ответы, что-то вроде:
200 OK {"ok": false, "error": "some error"}
handle_error – основывается на статус кодах (>= 300)
Вы можете наследовать эти методы в собственных клиентах. На нашем примере, это выглядит так.
Опишем сами классы ошибок:
class MaxException(Exception): pass class InvalidRequestException(MaxException): pass class AuthenticationException(MaxException): pass class ResourceNotFoundException(MaxException): pass class MethodNotAllowedException(MaxException): pass class RateLimitException(MaxException): pass class ServiceUnavailableException(MaxException): pass
Обработка ошибок на уровне клиента выглядит так:
from unihttp.http import HTTPResponse class MaxClient(NiquestsAsyncClient): ... def handle_error(self, response: HTTPResponse, method: BaseMethod): error_by_status_codes = { 400: InvalidRequestException, 401: AuthenticationException, 404: ResourceNotFoundException, 405: MethodNotAllowedException, 429: RateLimitException, 503: ServiceUnavailableException, } error = error_by_status_codes.get(response.status_code, MaxException) raise error(response.data)
В сам метод у нас передается response: HTTPResponse, нормализованный класс самого unihttp, который содержит: status_code, headers, data, cookies и raw_response из библиотеки, которую вы используете в качестве бэкэнда для клиента.
Основываясь на этих данных, вы можете выбрасывать любую ошибку, в зависимости от требуемой вам логики.
Middlewares
В библиотеке есть механизм мидлварей, который позволяет вам писать собственные мидлвари, например: логировать все исходящие запросы и ответы на них.
В связи с тем, что в библиотеке поддерживается два вида клиентов: синхронные и асинхронные, есть два вида мидлварей:
синхронные (для синхронных клиентов) – используется протокол
Middlewareасинхронные (для асинхронных клиентов) – используется протокол
AsyncMiddleware
Рассмотрим мидлварь простейшего логирования самих запросов и их ответов:
from unihttp.middlewares import AsyncMiddleware, AsyncHandler class LoggingMiddleware(AsyncMiddleware): async def handle( self, request: HTTPRequest, next_handler: AsyncHandler ) -> HTTPResponse: print("Outgoing request:", request) response = await next_handler(request) print("Outgoing response:", response) return response
Мидлварь выглядит собственно так просто, и никаких сложных вещей здесь нет. В самом клиенте вы их можете указывать следующим образом:
class MaxClient(NiquestsAsyncClient): def __init__( self, token: str, middleware: list[AsyncMiddleware] | None = None, ) -> None: super().__init__( ..., middleware=middleware ) client = MaxClient(token=TOKEN, middleware=[LoggingMiddleware()])
Возможные способы вызова методов
Стоит отметить, что в библиотеке существует два способа вызывать какой-либо метод.
bind_method – этот способ мы применили с вами немного ранее. Здесь стоит отметить, что его можно использовать в любом классе, который содержит в себе call_method
call_method – этот способ есть в любом клиенте, и его можно использовать следующим образом:
class MaxClient(NiquestsAsyncClient): ... async def get_me(self): return await self.call_method(GetMe())
Это публичный метод, поэтому он доступен к вызову в том числе и просто к инстансу вашего клиента.
json_loads / json_dumps
При инициализации клиента, вы также можете передавать собственные функции для сериализации и десериализации данных из JSON. Например, если вы хотите использовать не стандартную библиотеку json, а ujson, orjson или иные библиотеки.
class MaxClient(NiquestsAsyncClient): def __init__(self, token: str) -> None: super().__init__( ..., json_dumps=lambda x: orjson.dumps(x).decode(), json_loads=orjson.loads )
Сериализация данных
Мы с вами создавали класс самого клиента, и теперь вернемся к объяснениям про параметры request_dumper и response_loader.
В библиотеке доступно несколько вариантов для (де)сериализации данных:
adaptix (рекомендую)
pydantic
Рассмотрим их далее:
Adaptix
adaptix – очень мощная и гибкая библиотека для работы с сериализацией, десериализации и конверсии данных.
Что можно делать с помощью адаптикса:
менять именование параметров на входе / выходе, например: вам необходимо отправлять
userId, правило для работы может выглядеть так:name_mapping(GetUser, map={"user_id": "userId"}. Это правило в том числе может быть применимо и к всем методам:name_mapping(OriginSubclassLSC(BaseMethod), map={"extra_meow": "ExtraMeowParameter"})сериализация данных в нужный формат, возможно вам необходимо приводить дату к формату год-месяц-день, правило будет выглядеть так:
dumper(datetime.datetime, lambda x: x.strptime("%Y-%m-%d"))
Отдельно стоит упомянуть, в unihttp реализован for_marker – предикат, который позволяет применять правила к маркерам запроса (Query, Body и т.д.), например, нам необходимо все списки в Query сериализовывать в список значений через запятую. Мы можем сделать это следующим способом:
dumper( for_marker(QueryMarker, P[list[str]]), lambda x: ",".join(x) )
У adaptix можно очень гибко настраивать в том числе и объекты применения правил, например:
их можно применять к базовым классам
к конкретным классам
к определенным полям классов
к самим типам данным
какие-то правила применимы в целом к реторте
Можно также и многое иное, но почитать подробнее об этом можно в самой библиотеке адаптикса ;)
Pydantic
Pydantic — библиотека для Python, предназначенная для валидации и трансформации данных.
Несомненно, что вы все слышали о ней. Использовать pydantic для (де)сериализации данных можно следующим образом.
from unihttp.clients.niquests import NiquestsAsyncClient from unihttp.serializers.pydantic import PydanticLoader, PydanticDumper class MaxClient(NiquestsAsyncClient): def __init__(self, token: str) -> None: super().__init__( # ... request_dumper=PydanticLoader, response_loader=PydanticDumper, )
На этом обзор unihttp подошел к концу.
Буду рад вашей обратной связи и звездочкам на репозитории ;)
Дополнительные ссылки:
Репозиторий – https://github.com/goduni/unihttp
Документация adaptix – https://adaptix.readthedocs.io/
Фреймворк, который использует unihttp для работы с Max API – https://github.com/K1rL3s/maxo
