diff --git a/examples/transaction.py b/examples/transaction.py index 4e77da1..4d0ead0 100644 --- a/examples/transaction.py +++ b/examples/transaction.py @@ -1,16 +1,15 @@ -from pyspapi import SPAPI from asyncio import get_event_loop +from pyspapi import SPAPI + spapi = SPAPI(card_id='CARD_ID', token='TOKEN') async def main(): new_balance = await spapi.create_transaction(receiver='77552', amount=1, - comment="test" - ) + comment='test') print(new_balance) - loop = get_event_loop() loop.run_until_complete(main()) diff --git a/pyspapi/__init__.py b/pyspapi/__init__.py index 0944b0c..bd97613 100644 --- a/pyspapi/__init__.py +++ b/pyspapi/__init__.py @@ -1,10 +1,9 @@ +from .api import * from .spworlds import * from .types import * -from .api import * - __author__ = 'deesiigneer' __url__ = 'https://github.com/deesiigneer/pyspapi' __description__ = 'API wrapper for SP servers written in Python.' __license__ = 'MIT' -__version__ = "3.1.2" +__version__ = "3.2.0" diff --git a/pyspapi/api/api.py b/pyspapi/api/api.py index 18bd9b1..a4311e0 100644 --- a/pyspapi/api/api.py +++ b/pyspapi/api/api.py @@ -1,23 +1,32 @@ +import asyncio +import json from base64 import b64encode from logging import getLogger -import aiohttp -import json +from typing import Optional, Any, Dict +import aiohttp + +from ..exceptions import ValidationError, SPAPIError log = getLogger('pyspapi') class APISession(object): - """ Holds aiohttp session for its lifetime and wraps different types of request """ - def __init__(self, card_id: str, token: str, timeout=5, sleep_time=0.2, retries=0): - self.__url = "https://spworlds.ru/" + def __init__(self, card_id: str, + token: str, + timeout: int = 5, + sleep_time: float = 0.2, + retries: int = 0, + raise_exception: bool = False): + self.__url = "https://spworlds.ru/api/public/" self.__id = card_id self.__token = token self.__sleep_timeout = sleep_time self.__retries = retries self.__timeout = timeout - self.session = None + self.__raise_exception = raise_exception + self.session: Optional[aiohttp.ClientSession] = None async def __aenter__(self): self.session = aiohttp.ClientSession( @@ -29,62 +38,50 @@ class APISession(object): await self.session.close() self.session = None - def __get_url(self, endpoint: str) -> str: - """ Get URL for requests """ - url = self.__url - api = "api/public" - return f"{url}{api}/{endpoint}" + async def request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Any: + url = self.__url + endpoint + headers = { + 'Authorization': f"Bearer {str(b64encode(str(f'{self.__id}:{self.__token}').encode('utf-8')), 'utf-8')}", + 'User-Agent': 'https://github.com/deesiigneer/pyspapi', + "Content-Type": "application/json", + } - async def __request(self, method: str, endpoint: str, data=None): - url = self.__get_url(endpoint) - response = await self.session.request( - method=method, - url=url, - json=data, - headers={'Authorization': f"Bearer {str(b64encode(str(f'{self.__id}:{self.__token}').encode('utf-8')), 'utf-8')}", - 'User-Agent': 'https://github.com/deesiigneer/pyspapi'}, - ssl=True - ) - if response.status not in [200, 201]: - message = await response.json() - raise aiohttp.ClientResponseError( - code=response.status, - message=message['message'], - headers=response.headers, - history=response.history, - request_info=response.request_info - ) - return response + attempt = 0 + while True: + attempt += 1 + if attempt > 1: + log.warning(f'[pyspapi] Repeat attempt {attempt}: {method.upper()} {url}') + try: + async with self.session.request(method, url, json=data, headers=headers) as resp: + if resp.status == 422: + errors = await resp.json() + log.error(f"[pyspapi] Validation error: {errors}") + if self.__raise_exception: + raise ValidationError(errors) + return None - async def get(self, endpoint, **kwargs): - """ GET requests """ - try: - return await self.__request("GET", endpoint, None, **kwargs) - except aiohttp.ClientResponseError as e: - log.error(f"GET request to {endpoint} failed with status {e.status}: {e.message}") - except aiohttp.ClientError as e: - log.error(f"GET request to {endpoint} failed: {e}") - except Exception as e: - log.error(f"GET request to {endpoint} failed: {e}") + if resp.status >= 400: + content = await resp.text() + log.error(f"[pyspapi] API error {resp.status}: {content}") + if self.__raise_exception: + raise SPAPIError(resp.status, content) + return None - async def post(self, endpoint, data, **kwargs): - """ POST requests """ - try: - return await self.__request("POST", endpoint, data, **kwargs) - except aiohttp.ClientResponseError as e: - log.error(f"POST request to {endpoint} failed with status {e.status}: {e.message}") - except aiohttp.ClientError as e: - log.error(f"POST request to {endpoint} failed: {e}") - except Exception as e: - log.error(f"POST request to {endpoint} failed: {e}") + return await resp.json() + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + log.exception(f"[pyspapi] Connection error: {e}") + if attempt > self.__retries: + return None + await asyncio.sleep(self.__sleep_timeout) - async def put(self, endpoint, data, **kwargs): - """ PUT requests """ - try: - return await self.__request("PUT", endpoint, data, **kwargs) - except aiohttp.ClientResponseError as e: - log.error(f"PUT request to {endpoint} failed with status {e.status}: {e.message}") - except aiohttp.ClientError as e: - log.error(f"PUT request to {endpoint} failed: {e}") - except Exception as e: - log.error(f"PUT request to {endpoint} failed: {e}") \ No newline at end of file + async def get(self, endpoint: str) -> Any: + async with self: + return await self.request("GET", endpoint) + + async def post(self, endpoint: str, data: Optional[Dict] = None) -> Any: + async with self: + return await self.request("POST", endpoint, data) + + async def put(self, endpoint: str, data: Optional[Dict] = None) -> Any: + async with self: + return await self.request("PUT", endpoint, data) diff --git a/pyspapi/exceptions.py b/pyspapi/exceptions.py index e69de29..b4b66b1 100644 --- a/pyspapi/exceptions.py +++ b/pyspapi/exceptions.py @@ -0,0 +1,25 @@ +class SPAPIError(Exception): + """ + Базовая ошибка для всех исключений, связанных с API SPWorlds. + """ + + def __init__(self, status_code: int, message: str): + self.status_code = status_code + self.message = message + super().__init__(f"[{status_code}] {message}") + + def __str__(self): + return f"SPAPIError: [{self.status_code}] {self.message}" + + +class ValidationError(SPAPIError): + """ + Ошибка валидации. + """ + + def __init__(self, errors): + self.errors = errors + super().__init__(422, f"Validation failed: {errors}") + + def __str__(self): + return f"ValidationError: {self.errors}" diff --git a/pyspapi/spworlds.py b/pyspapi/spworlds.py index 726efc8..81ca7c5 100644 --- a/pyspapi/spworlds.py +++ b/pyspapi/spworlds.py @@ -1,21 +1,30 @@ +from base64 import b64encode +from hashlib import sha256 +from hmac import new, compare_digest +from typing import Optional + from .api import APISession from .types import User from .types.me import Account from .types.payment import Item -from hmac import new, compare_digest -from hashlib import sha256 -from base64 import b64encode -import aiohttp __all__ = ['SPAPI'] class SPAPI(APISession): """ - Представляет собой клиент API для взаимодействия с конкретным сервисом. + pyspapi — высокоуровневый клиент для взаимодействия с SPWorldsAPI. + + Предоставляет удобные методы для работы с балансом карты, вебхуками, + информацией о пользователе, транзакциями и платежами, а также верификацией вебхуков. """ - def __init__(self, card_id=None, token=None, timeout=5, sleep_time=0.2, retries=0): + def __init__(self, card_id: str, + token: str, + timeout: int = 5, + sleep_time: float = 0.2, + retries: int = 0, + raise_exception: bool = False): """ Инициализирует объект SPAPI. @@ -24,13 +33,15 @@ class SPAPI(APISession): :param token: Токен API. :type token: str :param timeout: Таймаут для запросов API в секундах. По умолчанию 5. - :type timeout: int, optional + :type timeout: int :param sleep_time: Время ожидания между повторными запросами в секундах. По умолчанию 0.2. - :type sleep_time: float, optional + :type sleep_time: float :param retries: Количество повторных попыток для неудачных запросов. По умолчанию 0. - :type retries: int, optional + :type retries: int + :param raise_exception: Поднимать исключения при ошибке, если True. + :type raise_exception: bool """ - super().__init__(card_id, token, timeout, sleep_time, retries) + super().__init__(card_id, token, timeout, sleep_time, retries, raise_exception) self.__card_id = card_id self.__token = token @@ -38,67 +49,48 @@ class SPAPI(APISession): """ Возвращает строковое представление объекта SPAPI. """ - return "%s(%s)" % ( - self.__class__.__name__, - self.__dict__ - ) - - async def __get(self, method): - """ - Выполняет GET-запрос к API. - - :param method: Метод API для вызова. - :type method: str - - :return: JSON-ответ от API. - :rtype: dict - """ - async with APISession(self.__card_id, self.__token) as session: - response = await session.get(method) - response = await response.json() - return response + return f"{self.__class__.__name__}({vars(self)})" @property - async def balance(self): + async def balance(self) -> Optional[int]: """ Получает текущий баланс карты. :return: Текущий баланс карты. :rtype: int """ - card = await self.__get('card') - return card['balance'] + return int((await super().get('card'))['balance']) @property - async def webhook(self) -> str: + async def webhook(self) -> Optional[str]: """ Получает URL вебхука, связанного с картой. :return: URL вебхука. :rtype: str """ - card = await self.__get('card') - return card['webhook'] + return str((await super().get('card'))['webhook']) @property - async def me(self): + async def me(self) -> Optional[Account]: """ Получает информацию об аккаунте текущего пользователя. :return: Объект Account, представляющий аккаунт текущего пользователя. - :rtype: Account + :rtype: :class:`Account` """ - me = await self.__get('accounts/me') + me = await super().get('accounts/me') return Account( account_id=me['id'], username=me['username'], + minecraftuuid=me['minecraftUUID'], status=me['status'], roles=me['roles'], - city=me['city'], + cities=me['cities'], cards=me['cards'], created_at=me['createdAt']) - async def get_user(self, discord_id: int) -> User: + async def get_user(self, discord_id: int) -> Optional[User]: """ Получает информацию о пользователе по его ID в Discord. @@ -106,13 +98,13 @@ class SPAPI(APISession): :type discord_id: int :return: Объект User, представляющий пользователя. - :rtype: User + :rtype: :class:`User` """ - user = await self.__get(f'users/{discord_id}') - cards = await self.__get(f"accounts/{user['username']}/cards") + user = await super().get(f'users/{discord_id}') + cards = await super().get(f"accounts/{user['username']}/cards") return User(user['username'], user['uuid'], cards) - async def create_transaction(self, receiver: str, amount: int, comment: str): + async def create_transaction(self, receiver: str, amount: int, comment: str) -> Optional[int]: """ Создает транзакцию. @@ -126,17 +118,15 @@ class SPAPI(APISession): :return: Баланс после транзакции. :rtype: int """ - async with APISession(self.__card_id, self.__token) as session: - data = { - 'receiver': receiver, - 'amount': amount, - 'comment': comment - } - res = await session.post('transactions', data) - res = await res.json() - return res['balance'] + data = { + 'receiver': receiver, + 'amount': amount, + 'comment': comment + } - async def create_payment(self, webhook_url: str, redirect_url: str, data: str, items) -> str: + return int((await super().post('transactions', data))['balance']) + + async def create_payment(self, webhook_url: str, redirect_url: str, data: str, items: list[Item]) -> Optional[str]: """ Создает платеж. @@ -148,40 +138,30 @@ class SPAPI(APISession): :type data: str :param items: Элементы, включаемые в платеж. - :return: URL для платежа. + :return: URL для платежа или None при ошибке. :rtype: str """ - async with APISession(self.__card_id, self.__token) as session: - data = { - 'items': items, - 'redirectUrl': redirect_url, - 'webhookUrl': webhook_url, - 'data': data - } - res = await session.post('payments',data) - res = await res.json() - return res['url'] + data = { + 'items': items, + 'redirectUrl': redirect_url, + 'webhookUrl': webhook_url, + 'data': data + } - async def update_webhook(self, url: str): + return str((await super().post('payments', data))['url']) + + async def update_webhook(self, url: str) -> Optional[dict]: """ Обновляет URL вебхука, связанного с картой. :param url: Новый URL вебхука. - :type url: str - - :return: JSON-ответ от API. - :rtype: dict + :return: Ответ API в виде словаря или None при ошибке. """ - async with APISession(self.__card_id, self.__token) as session: - data = { - 'url': url - } - res = await session.put(endpoint='card/webhook', data=data) - if res: - res = await res.json() - return res + data = {'url': url} - def webhook_verify(self, data: str, header) -> bool: + return await super().put('card/webhook', data) + + def webhook_verify(self, data: str, header: str) -> bool: """ Проверяет достоверность вебхука. diff --git a/pyspapi/types/me.py b/pyspapi/types/me.py index 315f0af..e620b65 100644 --- a/pyspapi/types/me.py +++ b/pyspapi/types/me.py @@ -1,49 +1,61 @@ class City: - def __init__( - self, - city_id=None, - name=None, - description=None, - x_cord=None, - z_cord=None, - is_mayor=None, - ): + def __init__(self, city_id=None, name=None, x=None, z=None, nether_x=None, nether_z=None, lane=None, role=None, + created_at=None): self._id = city_id self._name = name - self._description = description - self._x_cord = x_cord - self._z_cord = z_cord - self._isMayor = is_mayor + self._x = x + self._z = z + self._nether_x = nether_x + self._nether_z = nether_z + self._lane = lane + self._role = role + self._created_at = created_at @property def id(self): return self._id - @property - def description(self): - return self._description - @property def name(self): return self._name @property - def x_cord(self): - return self._x_cord + def x(self): + return self._x @property - def z_cord(self): - return self._z_cord + def z(self): + return self._z @property - def mayor(self): - return self._isMayor + def nether_x(self): + return self._nether_x + + @property + def nether_z(self): + return self._nether_z + + @property + def lane(self): + return self._lane + + @property + def role(self): + return self._role + + @property + def created_at(self): + return self._created_at def __repr__(self): - return f"City(id={self.id}, name={self.name}, description={self.description}, x={self.x_cord}, z={self.z_cord}, is_mayor={self.mayor})" + return ( + f"City(id={self._id}, name={self._name}, x={self._x}, z={self._z}, " + f"nether_x={self._nether_x}, nether_z={self._nether_z}, lane={self._lane}, role={self._role}, " + f"created_at={self._created_at})" + ) -class Cards: +class Card: def __init__(self, card_id=None, name=None, number=None, color=None): self._id = card_id self._name = name @@ -67,18 +79,32 @@ class Cards: return self._color def __repr__(self): - return f"Card(id={self.id}, name={self.name}, number={self.number}, color={self.color})" + return f"Card(id={self._id}, name={self._name}, number={self._number}, color={self._color})" class Account: - def __init__(self, account_id, username, status, roles, created_at, cards, city): + def __init__(self, account_id, username, minecraftuuid, status, roles, created_at, cards, cities): self._id = account_id self._username = username + self._minecraftuuid = minecraftuuid self._status = status self._roles = roles - self._city = City(**city) if city else None + self._cities = [ + City( + city_id=city['city_id'], + name=city['name'], + x=city['x'], + z=city['z'], + nether_x=city['nether_x'], + nether_z=city['nether_z'], + lane=city['lane'], + role=city['role'], + created_at=city['created_at'], + ) + for city in cities + ] self._cards = [ - Cards( + Card( card_id=card["id"], name=card["name"], number=card["number"], @@ -96,6 +122,10 @@ class Account: def username(self): return self._username + @property + def minecraftuuid(self): + return self._minecraftuuid + @property def status(self): return self._status @@ -105,8 +135,8 @@ class Account: return self._roles @property - def city(self): - return self._city + def cities(self): + return self._cities @property def cards(self): @@ -117,4 +147,6 @@ class Account: return self._created_at def __repr__(self): - return f"Account(id={self.id}, username={self.username}, status={self.status}, roles={self.roles}, city={self.city}, cards={self.cards}, created_at={self.created_at})" + return (f"Account(id={self._id}, username={self._username}, minecraftUUID={self._minecraftuuid}, " + f"status={self._status}, roles={self._roles}, cities={self._cities}, cards={self._cards}, " + f"created_at={self._created_at})")