feat: add endpoints desc

This commit is contained in:
2026-05-11 19:50:25 +03:00
parent 852ee9ec2e
commit 46b1e336d9
22 changed files with 236 additions and 95 deletions

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger,IReceipt from src.application.contracts import ILogger,IReceipt
from src.application.domain.enums import PaymentStatus from src.application.domain.enums import PaymentStatus
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException,NotFoundException,PaymentMetadataException,ReceiptDataException
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -17,19 +17,19 @@ class CreateCryptoTransferCompletedCommand:
@transactional @transactional
async def __call__(self, *, order_id: str, user_id: str, web3_transaction_hash: str | None = None) -> None: async def __call__(self, *, order_id: str, user_id: str, web3_transaction_hash: str | None = None) -> None:
if not order_id: if not order_id:
raise ApplicationException(status_code=400, message='Crypto transfer completed message missing order_id') raise PaymentMetadataException(message='Crypto transfer completed message missing order_id')
if not user_id: if not user_id:
raise ApplicationException(status_code=400, message='Crypto transfer completed message missing user_id') raise PaymentMetadataException(message='Crypto transfer completed message missing user_id')
await self._unit_of_work.payment_repository.update_crypto_transfer_completed( await self._unit_of_work.payment_repository.update_crypto_transfer_completed(
order_id=order_id, order_id=order_id,
web3_transaction_hash=web3_transaction_hash, web3_transaction_hash=web3_transaction_hash,
) )
user = await self._unit_of_work.user_repository.get(user_id) user = await self._unit_of_work.user_repository.get(user_id)
if user is None: if user is None:
raise ApplicationException(status_code=404, message='User not found') raise NotFoundException(message='User not found')
email = str(user.email or '').strip() email = str(user.email or '').strip()
if not email: if not email:
raise ApplicationException(status_code=400, message='User email missing') raise ReceiptDataException(message='User email missing')
customer_info = ' '.join( customer_info = ' '.join(
part part
for part in ( for part in (
@@ -40,28 +40,28 @@ class CreateCryptoTransferCompletedCommand:
if part if part
) )
if not customer_info: if not customer_info:
raise ApplicationException(status_code=400, message='User full name missing') raise ReceiptDataException(message='User full name missing')
customer_inn = str(user.inn or '').strip() customer_inn = str(user.inn or '').strip()
if not customer_inn: if not customer_inn:
raise ApplicationException(status_code=400, message='User inn missing') raise ReceiptDataException(message='User inn missing')
if user.birth_date is None: if user.birth_date is None:
raise ApplicationException(status_code=400, message='User birth date missing') raise ReceiptDataException(message='User birth date missing')
customer_birthday = f'{user.birth_date.isoformat()}T12:00:00.000Z' customer_birthday = f'{user.birth_date.isoformat()}T12:00:00.000Z'
order = await self._unit_of_work.order_repository.get_by_id(order_id) order = await self._unit_of_work.order_repository.get_by_id(order_id)
if order is None: if order is None:
raise ApplicationException(status_code=404, message='Order not found') raise NotFoundException(message='Order not found')
if order.total_price is None: if order.total_price is None:
raise ApplicationException(status_code=400, message='Order total price missing for receipt') raise ReceiptDataException(message='Order total price missing for receipt')
if order.service_fee is None: if order.service_fee is None:
raise ApplicationException(status_code=400, message='Order service fee missing for receipt') raise ReceiptDataException(message='Order service fee missing for receipt')
total_amount = Decimal(str(order.total_price)).quantize(Decimal('0.01')) total_amount = Decimal(str(order.total_price)).quantize(Decimal('0.01'))
service_fee = Decimal(str(order.service_fee)).quantize(Decimal('0.01')) service_fee = Decimal(str(order.service_fee)).quantize(Decimal('0.01'))
principal_amount = (total_amount - service_fee).quantize(Decimal('0.01')) principal_amount = (total_amount - service_fee).quantize(Decimal('0.01'))
if principal_amount < 0: if principal_amount < 0:
raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative') raise ReceiptDataException(message='Invalid receipt amounts: principal negative')
try: try:
receipt_response = await self._receipt.create_receipt( receipt_response = await self._receipt.create_receipt(

View File

@@ -7,7 +7,7 @@ from src.application.contracts import ILogger
from src.application.contracts import IItPayService from src.application.contracts import IItPayService
from src.application.domain.entities.order import OrderEntity from src.application.domain.entities.order import OrderEntity
from src.application.domain.enums import OrderStatus from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import PriceChangedException
from src.application.services import PaymentQuoteService from src.application.services import PaymentQuoteService
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.order import CreateOrder from src.presentation.schemas.order import CreateOrder
@@ -40,7 +40,7 @@ class CreateOrderCommand:
actual_total_price = quote.total_price actual_total_price = quote.total_price
if actual_total_price > payment_data.total_price * Decimal('1.01'): if actual_total_price > payment_data.total_price * Decimal('1.01'):
self._logger.error('Price has changed, please refresh and try again') self._logger.error('Price has changed, please refresh and try again')
raise ApplicationException(status_code=409, message='Price has changed, please refresh and try again') raise PriceChangedException()
order = OrderEntity( order = OrderEntity(
user_id=user_id, user_id=user_id,

View File

@@ -3,7 +3,7 @@ from ulid import ULID
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger,IQueueMessanger from src.application.contracts import ILogger,IQueueMessanger
from src.application.domain.enums import PaymentStatus from src.application.domain.enums import PaymentStatus
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import PaymentMetadataException
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
@@ -25,9 +25,9 @@ class CreatePaymentCommand:
trace_id = str(metadata.get('trace_id') or self._logger.get_trace_id()) trace_id = str(metadata.get('trace_id') or self._logger.get_trace_id())
self._logger.set_trace_id(trace_id) self._logger.set_trace_id(trace_id)
if not order_id: if not order_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing order_id') raise PaymentMetadataException(message='Itpay webhook metadata missing order_id')
if not user_id: if not user_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing user_id') raise PaymentMetadataException(message='Itpay webhook metadata missing user_id')
payment_created = await self._unit_of_work.payment_repository.create_completed( payment_created = await self._unit_of_work.payment_repository.create_completed(
user_id=user_id, user_id=user_id,
order_id=order_id, order_id=order_id,

View File

@@ -2,9 +2,17 @@ from src.application.domain.exceptions.application_exception import ApplicationE
from src.application.domain.exceptions.bad_gateway_exception import BadGatewayException from src.application.domain.exceptions.bad_gateway_exception import BadGatewayException
from src.application.domain.exceptions.bad_request_exception import BadRequestException from src.application.domain.exceptions.bad_request_exception import BadRequestException
from src.application.domain.exceptions.conflict_exception import ConflictException from src.application.domain.exceptions.conflict_exception import ConflictException
from src.application.domain.exceptions.csrf_exception import CsrfException
from src.application.domain.exceptions.forbidden_exception import ForbiddenException from src.application.domain.exceptions.forbidden_exception import ForbiddenException
from src.application.domain.exceptions.internal_server_exception import InternalServerException from src.application.domain.exceptions.internal_server_exception import InternalServerException
from src.application.domain.exceptions.jwt_exception import JwtException
from src.application.domain.exceptions.not_found_exception import NotFoundException from src.application.domain.exceptions.not_found_exception import NotFoundException
from src.application.domain.exceptions.payment_metadata_exception import PaymentMetadataException
from src.application.domain.exceptions.payment_provider_exception import PaymentProviderException
from src.application.domain.exceptions.price_changed_exception import PriceChangedException
from src.application.domain.exceptions.receipt_data_exception import ReceiptDataException
from src.application.domain.exceptions.receipt_provider_exception import ReceiptProviderException
from src.application.domain.exceptions.rate_limit_exception import RateLimitException
from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException
from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException
@@ -13,9 +21,17 @@ __all__ = [
'BadGatewayException', 'BadGatewayException',
'BadRequestException', 'BadRequestException',
'ConflictException', 'ConflictException',
'CsrfException',
'ForbiddenException', 'ForbiddenException',
'InternalServerException', 'InternalServerException',
'JwtException',
'NotFoundException', 'NotFoundException',
'PaymentMetadataException',
'PaymentProviderException',
'PriceChangedException',
'ReceiptDataException',
'ReceiptProviderException',
'RateLimitException',
'ServiceUnavailableException', 'ServiceUnavailableException',
'UnauthorizedException', 'UnauthorizedException',
] ]

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class CsrfException(ApplicationException):
def __init__(
self,
message: str = 'CSRF token invalid',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=403,message=message,headers=headers)

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class JwtException(ApplicationException):
def __init__(
self,
message: str = 'Invalid token',
status_code: int = 401,
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=status_code,message=message,headers=headers)

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class PaymentMetadataException(ApplicationException):
def __init__(
self,
message: str = 'Payment metadata invalid',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=400,message=message,headers=headers)

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class PaymentProviderException(ApplicationException):
def __init__(
self,
message: str = 'Payment provider error',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=502,message=message,headers=headers)

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class PriceChangedException(ApplicationException):
def __init__(
self,
message: str = 'Price has changed, please refresh and try again',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=409,message=message,headers=headers)

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class RateLimitException(ApplicationException):
def __init__(
self,
message: str = 'Too Many Requests',
status_code: int = 429,
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=status_code,message=message,headers=headers)

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class ReceiptDataException(ApplicationException):
def __init__(
self,
message: str = 'Receipt data invalid',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=400,message=message,headers=headers)

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class ReceiptProviderException(ApplicationException):
def __init__(
self,
message: str = 'Receipt provider error',
status_code: int = 502,
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=status_code,message=message,headers=headers)

View File

@@ -7,7 +7,7 @@ import aiohttp
import orjson import orjson
from aiohttp import BasicAuth, ClientTimeout from aiohttp import BasicAuth, ClientTimeout
from src.application.contracts import IReceipt from src.application.contracts import IReceipt
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException,ReceiptProviderException
from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL
@@ -122,30 +122,30 @@ class ClaudeKassirClient(IReceipt):
async with session.post(url, json=payload, headers=headers, auth=auth) as resp: async with session.post(url, json=payload, headers=headers, auth=auth) as resp:
raw = (await resp.text()).strip() raw = (await resp.text()).strip()
if not raw: if not raw:
raise ApplicationException( raise ReceiptProviderException(
status_code=502,
message=f'Receipt provider empty response (HTTP {resp.status})', message=f'Receipt provider empty response (HTTP {resp.status})',
) )
try: try:
parsed: Any = orjson.loads(raw) parsed: Any = orjson.loads(raw)
except orjson.JSONDecodeError: except orjson.JSONDecodeError:
preview = raw[:240].replace('\n', ' ') preview = raw[:240].replace('\n', ' ')
raise ApplicationException( raise ReceiptProviderException(
status_code=502,
message=f'Receipt provider non-JSON response (HTTP {resp.status}): {preview}', message=f'Receipt provider non-JSON response (HTTP {resp.status}): {preview}',
) )
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
raise ApplicationException(status_code=502, message='Receipt provider invalid response') raise ReceiptProviderException(message='Receipt provider invalid response')
body = parsed body = parsed
if resp.status >= 400: if resp.status >= 400:
raise ApplicationException( raise ReceiptProviderException(
status_code=502,
message=str(body.get('Message') or 'Receipt provider error'), message=str(body.get('Message') or 'Receipt provider error'),
) )
if body.get('Success') is False: if body.get('Success') is False:
raise ApplicationException(status_code=409, message=str(body.get('Message') or 'Receipt provider rejected receipt')) raise ReceiptProviderException(
status_code=409,
message=str(body.get('Message') or 'Receipt provider rejected receipt'),
)
return body return body
except ApplicationException: except ApplicationException:
raise raise
except aiohttp.ClientError: except aiohttp.ClientError:
raise ApplicationException(status_code=502, message='Receipt provider unreachable') raise ReceiptProviderException(message='Receipt provider unreachable')

View File

@@ -8,7 +8,7 @@ from aiohttp import BasicAuth, ClientTimeout
from src.application.contracts.i_itpay_service import IItPayService from src.application.contracts.i_itpay_service import IItPayService
from src.application.domain.entities.order import OrderEntity from src.application.domain.entities.order import OrderEntity
from src.application.domain.enums import OrderStatus from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException,PaymentProviderException
class ItPayClient(IItPayService): class ItPayClient(IItPayService):
@@ -68,7 +68,7 @@ class ItPayClient(IItPayService):
except orjson.JSONDecodeError: except orjson.JSONDecodeError:
response_json = {'raw': response_text} response_json = {'raw': response_text}
if resp.status >= 400: if resp.status >= 400:
raise ApplicationException(status_code=502, message='Payment provider error') raise PaymentProviderException(message='Payment provider error')
body_raw = response_json.get('data') body_raw = response_json.get('data')
body = body_raw if isinstance(body_raw, dict) else response_json body = body_raw if isinstance(body_raw, dict) else response_json
@@ -112,4 +112,4 @@ class ItPayClient(IItPayService):
except ApplicationException: except ApplicationException:
raise raise
except aiohttp.ClientError: except aiohttp.ClientError:
raise ApplicationException(status_code=502, message='Payment provider unreachable') raise PaymentProviderException(message='Payment provider unreachable')

View File

@@ -3,7 +3,7 @@ import secrets
from typing import Any, Optional, Mapping from typing import Any, Optional, Mapping
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from src.application.contracts import ICsrfService from src.application.contracts import ICsrfService
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import CsrfException
from src.infrastructure.config.settings import settings from src.infrastructure.config.settings import settings
@@ -42,21 +42,12 @@ class CsrfService(ICsrfService):
try: try:
data = self._serializer.loads(token, max_age=self.TTL_SECONDS) data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
except SignatureExpired: except SignatureExpired:
raise ApplicationException( raise CsrfException(message='CSRF token expired')
status_code=403,
message='CSRF token expired',
)
except BadSignature: except BadSignature:
raise ApplicationException( raise CsrfException(message='CSRF token invalid')
status_code=403,
message='CSRF token invalid',
)
if expected_subject is not None and data.get('sub') != expected_subject: if expected_subject is not None and data.get('sub') != expected_subject:
raise ApplicationException( raise CsrfException(message='CSRF token subject mismatch')
status_code=403,
message='CSRF token subject mismatch',
)
return data return data
@@ -67,15 +58,9 @@ class CsrfService(ICsrfService):
def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None: def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None:
if not cookie_token or not header_token: if not cookie_token or not header_token:
raise ApplicationException( raise CsrfException(message='CSRF token missing')
status_code=403,
message='CSRF token missing',
)
if not secrets.compare_digest(cookie_token, header_token): if not secrets.compare_digest(cookie_token, header_token):
raise ApplicationException( raise CsrfException(message='CSRF token mismatch')
status_code=403,
message='CSRF token mismatch',
)
self.verify(cookie_token, expected_subject=expected_subject) self.verify(cookie_token, expected_subject=expected_subject)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from jose import jwt, ExpiredSignatureError, JWTError from jose import jwt, ExpiredSignatureError, JWTError
from src.application.contracts import ILogger, IJwtService from src.application.contracts import ILogger, IJwtService
from src.application.domain.dto import AccessTokenPayload from src.application.domain.dto import AccessTokenPayload
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException,InternalServerException,JwtException
from src.infrastructure.config.settings import settings from src.infrastructure.config.settings import settings
from src.infrastructure.vault import JwtKeyStore from src.infrastructure.vault import JwtKeyStore
@@ -17,7 +17,7 @@ class JwtService(IJwtService):
if payload.get('type') != 'access': if payload.get('type') != 'access':
self._logger.warning(f'Access token invalid type received_type={payload.get('type')}') self._logger.warning(f'Access token invalid type received_type={payload.get('type')}')
raise ApplicationException(status_code=401, message='Invalid token type') raise JwtException(message='Invalid token type')
try: try:
return AccessTokenPayload( return AccessTokenPayload(
@@ -32,7 +32,7 @@ class JwtService(IJwtService):
) )
except KeyError as exception: except KeyError as exception:
self._logger.warning(f'Access token missing claim error={str(exception)}') self._logger.warning(f'Access token missing claim error={str(exception)}')
raise ApplicationException(status_code=401, message=f'Missing token claim: {exception}') raise JwtException(message=f'Missing token claim: {exception}')
async def _decode_and_verify(self, token: str) -> dict: async def _decode_and_verify(self, token: str) -> dict:
kid: str | None = None kid: str | None = None
@@ -42,12 +42,12 @@ class JwtService(IJwtService):
kid = header.get('kid') kid = header.get('kid')
if not kid: if not kid:
self._logger.warning(f'JWT header missing kid header={header}') self._logger.warning(f'JWT header missing kid header={header}')
raise ApplicationException(status_code=401, message='Missing token header: kid') raise JwtException(message='Missing token header: kid')
received_alg = header.get('alg') received_alg = header.get('alg')
if received_alg != settings.JWT_ALGORITHM: if received_alg != settings.JWT_ALGORITHM:
self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}') self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}')
raise ApplicationException(status_code=401, message='Invalid token algorithm') raise JwtException(message='Invalid token algorithm')
public_pem = await self._key_store.get_public_key_for_kid(str(kid)) public_pem = await self._key_store.get_public_key_for_kid(str(kid))
@@ -58,7 +58,7 @@ class JwtService(IJwtService):
if not public_pem: if not public_pem:
self._logger.warning(f'JWT unknown kid kid={kid}') self._logger.warning(f'JWT unknown kid kid={kid}')
raise ApplicationException(status_code=401, message='Unknown token kid') raise JwtException(message='Unknown token kid')
options = { options = {
'verify_signature': True, 'verify_signature': True,
@@ -85,25 +85,25 @@ class JwtService(IJwtService):
if 'sid' not in payload: if 'sid' not in payload:
self._logger.warning(f'JWT missing sid claim kid={kid}') self._logger.warning(f'JWT missing sid claim kid={kid}')
raise ApplicationException(status_code=401, message='Missing token claim: sid') raise JwtException(message='Missing token claim: sid')
if 'type' not in payload: if 'type' not in payload:
self._logger.warning(f'JWT missing type claim kid={kid}') self._logger.warning(f'JWT missing type claim kid={kid}')
raise ApplicationException(status_code=401, message='Missing token claim: type') raise JwtException(message='Missing token claim: type')
return payload return payload
except ExpiredSignatureError as exception: except ExpiredSignatureError as exception:
self._logger.info(f'JWT expired kid={kid} error={str(exception)}') self._logger.info(f'JWT expired kid={kid} error={str(exception)}')
raise ApplicationException(status_code=401, message='Token expired') raise JwtException(message='Token expired')
except ApplicationException: except ApplicationException:
raise raise
except JWTError as exception: except JWTError as exception:
self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}') self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}')
raise ApplicationException(status_code=401, message='Invalid token') raise JwtException(message='Invalid token')
except Exception as exception: except Exception as exception:
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}') self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}')
raise ApplicationException(status_code=500, message='JWT decode failed') raise InternalServerException(message='JWT decode failed')

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import InternalServerException
from src.infrastructure.vault import create_hvac_client_from_approle,read_kv2_secret from src.infrastructure.vault import create_hvac_client_from_approle,read_kv2_secret
@@ -52,7 +52,7 @@ class JwtKeyStore:
@classmethod @classmethod
def get_instance(cls) -> 'JwtKeyStore': def get_instance(cls) -> 'JwtKeyStore':
if cls._instance is None: if cls._instance is None:
raise ApplicationException(status_code=500, message='JwtKeyStore not initialized') raise InternalServerException(message='JwtKeyStore not initialized')
return cls._instance return cls._instance
def _read_keyset_sync(self) -> JwtPublicKeySet: def _read_keyset_sync(self) -> JwtPublicKeySet:

View File

@@ -1,7 +1,7 @@
from fastapi import Depends, Request from fastapi import Depends, Request
from fastapi.security.utils import get_authorization_scheme_param from fastapi.security.utils import get_authorization_scheme_param
from src.application.contracts import IJwtService from src.application.contracts import IJwtService
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import UnauthorizedException
from src.application.domain.dto import AccessTokenPayload, AuthContext from src.application.domain.dto import AccessTokenPayload, AuthContext
from src.presentation.dependencies import get_jwt_service from src.presentation.dependencies import get_jwt_service
@@ -27,10 +27,10 @@ async def require_access_token(
) -> AuthContext: ) -> AuthContext:
token = _extract_access_token(request) token = _extract_access_token(request)
if not token: if not token:
raise ApplicationException(status_code=401, message='Not authenticated') raise UnauthorizedException(message='Not authenticated')
payload: AccessTokenPayload = await jwt_service.decode_access_token(token) payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
if payload.type != 'access': if payload.type != 'access':
raise ApplicationException(status_code=401, message='Invalid token type') raise UnauthorizedException(message='Invalid token type')
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload) return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)

View File

@@ -3,7 +3,7 @@ import inspect
from functools import wraps from functools import wraps
from typing import Callable, Awaitable, Any, Optional, Annotated from typing import Callable, Awaitable, Any, Optional, Annotated
from fastapi import Request, Header from fastapi import Request, Header
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import InternalServerException
from src.infrastructure.security import CsrfService from src.infrastructure.security import CsrfService
@@ -39,10 +39,7 @@ def csrf_protect(
break break
if request is None: if request is None:
raise ApplicationException( raise InternalServerException(message='Request is required for CSRF protection')
status_code=500,
message='Request is required for CSRF protection',
)
csrf = CsrfService() csrf = CsrfService()

View File

@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtim
from fastapi import Request from fastapi import Request
from redis.asyncio.client import Redis from redis.asyncio.client import Redis
from src.application.contracts import ILogger from src.application.contracts import ILogger
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import InternalServerException,RateLimitException
from src.infrastructure.logger import get_logger from src.infrastructure.logger import get_logger
from src.presentation.dependencies import get_redis from src.presentation.dependencies import get_redis
@@ -124,7 +124,7 @@ def rate_limit(
ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type] ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type]
except Exception as e: except Exception as e:
logger.error(f'RateLimit key_builder failed error={str(e)}') logger.error(f'RateLimit key_builder failed error={str(e)}')
raise ApplicationException(500, 'Rate limiter key_builder failed') raise InternalServerException(message='Rate limiter key_builder failed')
route = request.url.path route = request.url.path
method = request.method method = request.method
@@ -153,13 +153,12 @@ def rate_limit(
logger.warning(f'RateLimit fail-open activated key={redis_key}') logger.warning(f'RateLimit fail-open activated key={redis_key}')
return await func(*args, **kwargs) return await func(*args, **kwargs)
raise ApplicationException(503, 'Rate limiter unavailable') raise RateLimitException(status_code=503,message='Rate limiter unavailable')
if count > limit: if count > limit:
retry_after = max(ttl, 0) retry_after = max(ttl, 0)
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}') logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
raise ApplicationException( raise RateLimitException(
status_code=429,
message='Too Many Requests', message='Too Many Requests',
headers={'Retry-After': str(retry_after)}, headers={'Retry-After': str(retry_after)},
) )

View File

@@ -1,9 +1,16 @@
from fastapi import Request from fastapi import Request
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
from src.infrastructure.logger import logger
async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse: async def application_exception_handler(request: Request, exc: ApplicationException) -> ORJSONResponse:
logger.warning({
'event':'application_exception',
'path':request.url.path,
'status_code':exc.status_code,
'detail':exc.message,
})
detail = exc.message detail = exc.message
if 500 <= exc.status_code: if 500 <= exc.status_code:
detail = 'Internal Server Error' detail = 'Internal Server Error'

View File

@@ -26,6 +26,19 @@ orders_router = APIRouter(tags=['orders'])
payment_router = APIRouter(prefix='/payment', tags=['payments']) payment_router = APIRouter(prefix='/payment', tags=['payments'])
payments_router = APIRouter(tags=['payments']) payments_router = APIRouter(tags=['payments'])
ERROR_RESPONSES = {
400: {'model': ErrorResponse, 'description': 'Bad Request'},
401: {'model': ErrorResponse, 'description': 'Unauthorized'},
403: {'model': ErrorResponse, 'description': 'Forbidden'},
404: {'model': ErrorResponse, 'description': 'Not Found'},
409: {'model': ErrorResponse, 'description': 'Conflict'},
422: {'model': ErrorResponse, 'description': 'Validation Error'},
429: {'model': ErrorResponse, 'description': 'Too Many Requests'},
500: {'model': ErrorResponse, 'description': 'Internal Server Error'},
502: {'model': ErrorResponse, 'description': 'Bad Gateway'},
503: {'model': ErrorResponse, 'description': 'Service Unavailable'},
}
def _payment_config_response(quote: PaymentQuote) -> PaymentConfigResponse: def _payment_config_response(quote: PaymentQuote) -> PaymentConfigResponse:
return PaymentConfigResponse( return PaymentConfigResponse(
@@ -156,16 +169,7 @@ async def _websocket_auth_context(websocket: WebSocket, jwt_service: IJwtService
'/create', '/create',
response_model=CreateOrderResponse, response_model=CreateOrderResponse,
status_code=201, status_code=201,
responses={ responses=ERROR_RESPONSES,
400: {'model': ErrorResponse, 'description': 'Bad Request'},
401: {'model': ErrorResponse, 'description': 'Unauthorized'},
403: {'model': ErrorResponse, 'description': 'Forbidden'},
404: {'model': ErrorResponse, 'description': 'Not Found'},
409: {'model': ErrorResponse, 'description': 'Conflict'},
500: {'model': ErrorResponse, 'description': 'Internal Server Error'},
502: {'model': ErrorResponse, 'description': 'Bad Gateway'},
503: {'model': ErrorResponse, 'description': 'Service Unavailable'},
},
) )
@csrf_protect() @csrf_protect()
async def create_order( async def create_order(
@@ -200,7 +204,12 @@ async def create_order(
return content return content
@payment_router.get('/config', response_model=PaymentConfigResponse) @payment_router.get(
'/config',
response_model=PaymentConfigResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def payment_config( async def payment_config(
command: GetPaymentConfigCommand = Depends(get_payment_config_command), command: GetPaymentConfigCommand = Depends(get_payment_config_command),
) -> PaymentConfigResponse: ) -> PaymentConfigResponse:
@@ -208,7 +217,12 @@ async def payment_config(
return _payment_config_response(quote) return _payment_config_response(quote)
@payment_router.get('/quote', response_model=PaymentQuoteResponse) @payment_router.get(
'/quote',
response_model=PaymentQuoteResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def payment_quote( async def payment_quote(
usdt_amount: Decimal = Query(gt=0, decimal_places=2, max_digits=20), usdt_amount: Decimal = Query(gt=0, decimal_places=2, max_digits=20),
command: GetPaymentQuoteCommand = Depends(get_payment_quote_command), command: GetPaymentQuoteCommand = Depends(get_payment_quote_command),
@@ -217,7 +231,12 @@ async def payment_quote(
return _payment_quote_response(quote) return _payment_quote_response(quote)
@orders_router.get('/orders', response_model=OrdersResponse) @orders_router.get(
'/orders',
response_model=OrdersResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def list_orders( async def list_orders(
limit: int = Query(default=20, ge=1, le=100), limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
@@ -229,7 +248,12 @@ async def list_orders(
return OrdersResponse(status_code=200,orders=items,limit=limit,offset=offset) return OrdersResponse(status_code=200,orders=items,limit=limit,offset=offset)
@payments_router.get('/payments', response_model=PaymentsResponse) @payments_router.get(
'/payments',
response_model=PaymentsResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def list_payments( async def list_payments(
limit: int = Query(default=20, ge=1, le=100), limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
@@ -245,7 +269,12 @@ async def list_payments(
) )
@order_router.get('/{order_id}/status', response_model=OrderStatusResponse) @order_router.get(
'/{order_id}/status',
response_model=OrderStatusResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def order_status( async def order_status(
order_id: str, order_id: str,
auth: AuthContext = Depends(require_access_token), auth: AuthContext = Depends(require_access_token),
@@ -292,7 +321,12 @@ async def order_events(
trace_id_var.reset(token) trace_id_var.reset(token)
@order_router.get('/{order_id}', response_model=OrderDetailResponse) @order_router.get(
'/{order_id}',
response_model=OrderDetailResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def order_detail( async def order_detail(
order_id: str, order_id: str,
auth: AuthContext = Depends(require_access_token), auth: AuthContext = Depends(require_access_token),
@@ -302,7 +336,11 @@ async def order_detail(
return _order_detail_response(result.order,result.payment) return _order_detail_response(result.order,result.payment)
@order_router.post('/webhook/itpay') @order_router.post(
'/webhook/itpay',
status_code=200,
responses=ERROR_RESPONSES,
)
async def itpay_webhook( async def itpay_webhook(
request: Request, request: Request,
payment_command: CreatePaymentCommand = Depends(get_create_payment_command), payment_command: CreatePaymentCommand = Depends(get_create_payment_command),