unihttp – пишем декларативные API-клиенты на Python

unihttp – пишем декларативные API-клиенты на Python, image #1

Уверен, что все сталкивались с необходимостью работы с какими-либо API, но что делать, если у этого сервиса (внешнего или внутреннего) отсутствует библиотека, позволяющая лаконично вызывать нужные методы?

Если это единичный вызов – можно обернуть в функцию и используя голые requests / aiohttp отправлять запросы. В случае, если у вас идет тесное взаимодействие с этим API, или у вас приложение в принципе построено для работы с ним, то ваш код превращается в огромную простыню функций или вызовов requests / aiohttp и последующего трансформирования ответа в нужный тип.

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

В основном, мы решаем следующие вопросы в процессе размышления над библиотекой:

  1. Каким образом мы должны работать с ответами API?
  2. Как обрабатывать ошибки сервиса?
  3. Как выделить авторизацию и прокидывать собственный токен?
  4. Внешний вид библиотеки (ее интерфейс)

Не самые редкие случаи, могут требовать решения и иных вопросов, например: пре/пост обработка ответов 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)

Что мы здесь делаем:

  1. импортируем основу клиента – NiquestsAsyncClient
  2. инициализируем клиент:
    1. base_url – базовый урл API
    2. request_dumper – используется для дампа запроса
    3. response_loader – используется для десериализации ответов
    4. session – сюда мы передаем AsyncSession с хедерами, в которых у нас содержится авторизационный хедер
  3. используем 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()

Здесь у нас появились два метода параметров:

  1. Path-параметр chat_id
  2. Body-параметры: message_id, notify

notify является Omittable параметром, у него нет дефолтного значения в самом методе, и он не будет передан в тело запроса.

Вместо того, чтобы вручную собирать словари для заголовков или заниматься конкатенацией строк для URL, unihttp использует систему специальных маркеров. Это позволяет превратить сигнатуру метода в прозрачное описание HTTP-запроса.

В библиотеке есть 6 маркеров, которые закрывают 100% потребностей:

  1. Path – подставляет параметр в __url__
  2. Query – автоматически сериализует параметры в query-string
  3. Header – добавляет хедеры к запросу
  4. Body – параметры, которые должны быть отправлены в JSON-формате в теле запроса
  5. Form – параметры, которые используют стандартную URL-кодировку для передачи полей в теле сообщения
  6. 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) В сам метод у нас передается response: HTTPResponse, нормализованный класс самого unihttp, который содержит: status_code, headers, data, cookies и raw_response из библиотеки, которую вы используете в качестве бэкэнда для клиента.

Основываясь на этих данных, вы можете выбрасывать любую ошибку, в зависимости от требуемой вам логики.

Middlewares

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

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

  1. синхронные (для синхронных клиентов) – используется протокол Middleware
  2. асинхронные (для асинхронных клиентов) – используется протокол 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 responseraise error(response.data)

Мидлварь выглядит собственно так просто, и никаких сложных вещей здесь нет. В самом клиенте вы их можете указывать следующим образом:

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.

В библиотеке доступно несколько вариантов для (де)сериализации данных:

  1. adaptix (рекомендую)
  2. pydantic

Рассмотрим их далее:

Adaptix

adaptix – очень мощная и гибкая библиотека для работы с сериализацией, десериализации и конверсии данных.

Что можно делать с помощью адаптикса:

  1. менять именование параметров на входе / выходе, например: вам необходимо отправлять userId, правило для работы может выглядеть так: name_mapping(GetUser, map={"user_id": "userId"}. Это правило в том числе может быть применимо и к всем методам: name_mapping(OriginSubclassLSC(BaseMethod), map={"extra_meow": "ExtraMeowParameter"})
  2. сериализация данных в нужный формат, возможно вам необходимо приводить дату к формату год-месяц-день, правило будет выглядеть так: 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 можно очень гибко настраивать в том числе и объекты применения правил, например:

  1. их можно применять к базовым классам
  2. к конкретным классам
  3. к определенным полям классов
  4. к самим типам данным
  5. какие-то правила применимы в целом к реторте

Можно также и многое иное, но почитать подробнее об этом можно в самой библиотеке адаптикса ;)

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 подошел к концу.

Буду рад вашей обратной связи и звездочкам на репозитории ;)

Дополнительные ссылки:

  1. Репозиторий – https://github.com/goduni/unihttp
  2. Документация adaptix – https://adaptix.readthedocs.io/
  3. Фреймворк, который использует unihttp для работы с Max API – https://github.com/K1rL3s/maxo

Источник: https://habr.com/ru/articles/992844/

68 views·1 share