Skip to content

Commit

Permalink
Авторизация по OAuth (#243)
Browse files Browse the repository at this point in the history
  • Loading branch information
MSITETOP authored Jul 23, 2024
1 parent f9aa920 commit 0155a7c
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 8 deletions.
3 changes: 2 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

Внутри объекта ведётся учёт скорости отправки запросов к серверу, поэтому важно, чтобы все запросы приложения в отношении одного аккаунта с одного IP-адреса отправлялись из одного экземпляра `Bitrix`.

### Метод ` __init__(self, webhook: str, verbose: bool = True, respect_velocity_policy: bool = True, request_pool_size: int = 50, requests_per_second: float = 2.0, client: aiohttp.ClientSession = None):`
### Метод ` __init__(self, webhook: str, token_func: Awaitable = None, verbose: bool = True, respect_velocity_policy: bool = True, request_pool_size: int = 50, requests_per_second: float = 2.0, ssl: bool = True, client: aiohttp.ClientSession = None):`
Создаёт клиента для доступа к Битрикс24.

#### Параметры
- `webhook: str` - URL вебхука, полученного от сервера Битрикс.
- `token_func: Awaitable = None` - ссылка на асинхронную функцию, возвращающаю OAuth-токен для запросов к серверу. Если не указана, то OAuth-авторизация не используется.
- `verbose: bool = True` - показывать прогрессбар при выполнении запроса.
- `respect_velocity_policy: bool = True` - соблюдать политику Битрикса о скорости запросов.
- `request_pool_size: int = 50` - размер пула запросов, который
Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ API wrapper для Питона для быстрого получения да
- Высокоуровневые списочные методы для сокращения количества необходимого кода. Большинство операций занимают только одну строку кода. Обработка параллельных запросов, упаковка запросов в батчи и многое другое убрано "под капот".
- Позволяет задавать параметры запроса именно в таком виде, как они приведены в [документации к Bitrix24 REST API](https://dev.1c-bitrix.ru/rest_help/index.php). Параметры проверяются на корректность для облегчения отладки.
- Выполнение запросов автоматически сопровождается прогресс-баром из пакета `tqdm`, иллюстрирующим не только количество обработанных элементов, но и прошедшее и оставшееся время выполнения запроса.
- При работе с приложениями отслеживает устаревание токена авторизации и автоматически обновляет его через вызов функции, передаваемой пользователем.

### Синхронный и асинхронный клиенты
- Наличие асинхронного клиента позволяет использовать библиотеку для написания веб-приложений (например, телеграм-ботов).
Expand Down Expand Up @@ -160,7 +161,7 @@ results = bx.call_batch ({

```
### Асинхронные вызовы
Если требуется использование бибилиотеки в асинхронном коде, то вместо клиента `Bitrix()` создавайте клиент класса `BitrixAsync()`:
Если требуется использование бибилиотеки в асинхронном коде (или в ноутбуках), то вместо клиента `Bitrix()` создавайте клиент класса `BitrixAsync()`:
```python
from fast_bitrix24 import BitrixAsync
bx = BitrixAsync(webhook)
Expand All @@ -169,7 +170,30 @@ bx = BitrixAsync(webhook)
```python
leads = await bx.get_all('crm.lead.list')
```
## Как это работает

### Авторизация через OAuth
Если требуется авторизация через OAuth, то при инициализации клиента `Bitrix()` необходимо передать в параметре `token_func` ссылку на асинхронную функцию, которая будет возвращать токен авторизации:
```python
from fast_bitrix24 import Bitrix
import requests

async def get_new_token() -> str:
oauth_url = 'https://oauth.bitrix.info/oauth/token/'
params={
'grant_type': 'refresh_token',
'client_id': ...,
'client_secret': ...,
'refresh_token': ...
}
result = requests.get(oauth_url, timeout=10, params=params)
return result.json()["access_token"]

bx = Bitrix(webhook, token_func=get_new_token)
...
```
`token_func` будет вызываться каждый раз, когда необходимо получить впервые или обновить токен авторизации.

## Как работает библиотека
1. Перед обращением к серверу во всех методах класса `Bitrix` происходит проверка корректности самых популярных параметров, передаваемых к серверу, и поднимаются исключения `TypeError` и `ValueError` при наличии ошибок.
2. Cоздаются запросы на получение всех элементов из запрошенного списка.
3. Созданные запросы упаковываются в батчи по 50 запросов в каждом.
Expand Down
9 changes: 9 additions & 0 deletions fast_bitrix24/bitrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import functools as ft
from contextlib import contextmanager
from inspect import iscoroutinefunction
from typing import Iterable, Union

import aiohttp
Expand All @@ -25,9 +26,11 @@
class BitrixAsync:
"""Клиент для асинхронных запросов к Битрикс24."""

@beartype
def __init__(
self,
webhook: str,
token_func=None,
verbose: bool = True,
respect_velocity_policy: bool = True,
request_pool_size: int = 50,
Expand All @@ -40,6 +43,8 @@ def __init__(
Параметры:
- `webhook: str` - URL вебхука, полученного от сервера Битрикс
- `token_func: Awaitable = None` - асинхронная функция для получения
и обновления токена в случае работы с приложениями и OAuth-авторизацией
- `verbose: bool = True` - показывать ли прогрессбар при выполнении
запроса
- `respect_velocity_policy: bool = True` - соблюдать ли политику
Expand All @@ -55,8 +60,12 @@ def __init__(
пользователем. Ожидаеется, что пользователь сам откроет и закроет сессию.
"""

if token_func is not None and not iscoroutinefunction(token_func):
raise ValueError("`token_func` must be an async function.")

self.srh = ServerRequestHandler(
webhook=webhook,
token_func=token_func,
respect_velocity_policy=respect_velocity_policy,
request_pool_size=request_pool_size,
requests_per_second=requests_per_second,
Expand Down
51 changes: 48 additions & 3 deletions fast_bitrix24/srh.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class ServerError(Exception):
pass


class TokenRejectedError(Exception):
pass


RETRIED_ERRORS = (
ClientPayloadError,
ClientConnectionError,
Expand All @@ -54,13 +58,22 @@ class ServerRequestHandler:
def __init__(
self,
webhook: str,
token_func,
respect_velocity_policy: bool,
request_pool_size: int,
requests_per_second: float,
client,
ssl: bool = True,
):
self.webhook = self.standardize_webhook(webhook)

self.token_func = token_func
self.token = None

# token_received - флаг, что получение токена начало и не закончено
self.token_received = Event()
self.token_received.set()

self.respect_velocity_policy = respect_velocity_policy

self.active_runs = 0
Expand Down Expand Up @@ -145,25 +158,38 @@ async def single_request(self, method: str, params=None) -> dict:
"""Делает единичный запрос к серверу,
с повторными попытками при необходимости."""

# начальное получение токена
if self.token_func and not self.token:
await self.ensure_new_token()

while True:

try:
result = await self.request_attempt(method.strip().lower(), params)
self.success()
return result

except RETRIED_ERRORS as err: # all other exceptions will propagate
except TokenRejectedError:
await self.ensure_new_token()

except RETRIED_ERRORS as err:
self.failure(err)

# all other exceptions will propagate

async def request_attempt(self, method, params=None) -> dict:
"""Делает попытку запроса к серверу, ожидая при необходимости."""

try:
async with self.acquire(method):
logger.debug(f"Requesting {{'method': {method}, 'params': {params}}}")

params_with_auth = params.copy() if params else {}
if self.token:
params_with_auth["auth"] = self.token

async with self.session.post(
url=self.webhook + method, json=params, ssl=self.ssl
url=self.webhook + method, json=params_with_auth, ssl=self.ssl
) as response:
json = await response.json(encoding="utf-8")

Expand All @@ -174,10 +200,16 @@ async def request_attempt(self, method, params=None) -> dict:
return json

except ClientResponseError as error:

if error.status // 100 == 5: # ошибки вида 5XX
raise ServerError("The server returned an error") from error

raise
elif error.status == 401 and self.token_func:
raise TokenRejectedError(
"The server rejected the auth token"
) from error

raise # иначе повторяем полученное исключение

def add_throttler_records(self, method, params: dict, json: dict):
if "result_time" in json:
Expand Down Expand Up @@ -274,3 +306,16 @@ async def limit_concurrent_requests(self):
finally:
self.concurrent_requests -= 1
self.request_complete.set()

async def ensure_new_token(self):
"""Получает новый токен, если процесс получения токена еще не запущен,
или ждет его завершения."""

if self.token_received.is_set():
logger.debug("Requesting new token")

self.token_received.clear()
self.token = await self.token_func()
self.token_received.set()
else:
await self.token_received.wait()
65 changes: 65 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import pytest
from fast_bitrix24 import Bitrix
from fast_bitrix24.srh import ServerRequestHandler, TokenRejectedError

from typing import Dict, List, Union

class MockSRH(ServerRequestHandler):
def __init__(self, token_func, response: Union[Dict, List[Dict]]):
self.response = response if isinstance(response, list) else [response]
self.element_no = 0

super().__init__("https://google.com/path", token_func, False, 50, 2, None)

async def request_attempt(self, *args, **kwargs):
result = self.response[self.element_no]
try:
if isinstance(result, Exception):
raise result
else:
return result
finally:
self.element_no += 1


@pytest.mark.skip(reason="TODO")
def test_first_request():
# нужно проверить, что вызывается функция запроса токена при первом запросе

raise AssertionError

@pytest.mark.skip(reason="TODO")
def test_auth_success():
# нужно проверить, что серверу передается токен, полученный от token_func

raise AssertionError

@pytest.mark.skip(reason="TODO")
def test_auth_failure():
# нужно проверить, что вызывается функция запроса токена, если сервер вернул ошибку токена

raise AssertionError

@pytest.mark.skip(reason="TODO")
def test_abort_on_multiple_failures():
# нужно проверить, что если token_func регулярно возвращает токен, который отвергается сервером,
# то запрос оборвется после MAX_RETRIES неудачных попыток

raise AssertionError

def test_expired_token(bx_dummy):
# нужно проверить, что вызывается функция запроса токена, если токен истек

called_count = 0

async def token_func(*args, **kwargs):
nonlocal called_count
called_count += 1
return "abc"

bx_dummy.srh = MockSRH(token_func, [{}, TokenRejectedError(), {}])

bx_dummy.call("test", raw=True)
bx_dummy.call("test", raw=True)

assert called_count == 2 # начальный запрос + запрос после истечения токена
2 changes: 1 addition & 1 deletion tests/test_server_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def __init__(self, response: Union[Dict, List[Dict]]):
self.response = response if isinstance(response, list) else [response]
self.element_no = -1

super().__init__("https://google.com/path", False, 50, 2, None)
super().__init__("https://google.com/path", None, False, 50, 2, None)

async def single_request(self, *args, **kwargs):
self.element_no += 1
Expand Down
2 changes: 1 addition & 1 deletion tests/test_srh.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ async def mock_post(url, json, ssl):
mock_session = AsyncMock()
mock_session.post = mock_post

handler = ServerRequestHandler('https://google.com/webhook', True, 50, 2, mock_session)
handler = ServerRequestHandler('https://google.com/webhook', None, True, 50, 2, mock_session)

# Call the method
result = await handler.request_attempt('method', {'param': 'value'})
Expand Down

0 comments on commit 0155a7c

Please sign in to comment.