From 46b1e336d95435ad999c0f76c6f53a16d6769f4b Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Mon, 11 May 2026 19:50:25 +0300 Subject: [PATCH] feat: add endpoints desc --- ...reate_crypto_transfer_completed_command.py | 24 +++---- .../commands/create_order_command.py | 4 +- .../commands/create_payment_command.py | 6 +- src/application/domain/exceptions/__init__.py | 16 +++++ .../domain/exceptions/csrf_exception.py | 12 ++++ .../domain/exceptions/jwt_exception.py | 13 ++++ .../exceptions/payment_metadata_exception.py | 12 ++++ .../exceptions/payment_provider_exception.py | 12 ++++ .../exceptions/price_changed_exception.py | 12 ++++ .../domain/exceptions/rate_limit_exception.py | 13 ++++ .../exceptions/receipt_data_exception.py | 12 ++++ .../exceptions/receipt_provider_exception.py | 13 ++++ src/infrastructure/cloud_kassir/client.py | 20 +++--- src/infrastructure/itpay/client.py | 6 +- src/infrastructure/security/csrf.py | 27 ++----- src/infrastructure/security/jwt.py | 22 +++--- src/infrastructure/vault/keys.py | 4 +- src/presentation/decorators/auth.py | 6 +- src/presentation/decorators/csrf.py | 7 +- src/presentation/decorators/rate_limit.py | 9 ++- .../handler/application_exception_handler.py | 9 ++- src/presentation/routing/order.py | 72 ++++++++++++++----- 22 files changed, 236 insertions(+), 95 deletions(-) create mode 100644 src/application/domain/exceptions/csrf_exception.py create mode 100644 src/application/domain/exceptions/jwt_exception.py create mode 100644 src/application/domain/exceptions/payment_metadata_exception.py create mode 100644 src/application/domain/exceptions/payment_provider_exception.py create mode 100644 src/application/domain/exceptions/price_changed_exception.py create mode 100644 src/application/domain/exceptions/rate_limit_exception.py create mode 100644 src/application/domain/exceptions/receipt_data_exception.py create mode 100644 src/application/domain/exceptions/receipt_provider_exception.py diff --git a/src/application/commands/create_crypto_transfer_completed_command.py b/src/application/commands/create_crypto_transfer_completed_command.py index bb93ccb..3ce3eba 100644 --- a/src/application/commands/create_crypto_transfer_completed_command.py +++ b/src/application/commands/create_crypto_transfer_completed_command.py @@ -3,7 +3,7 @@ from decimal import Decimal from src.application.abstractions import IUnitOfWork from src.application.contracts import ILogger,IReceipt 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 @@ -17,19 +17,19 @@ class CreateCryptoTransferCompletedCommand: @transactional async def __call__(self, *, order_id: str, user_id: str, web3_transaction_hash: str | None = None) -> None: 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: - 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( order_id=order_id, web3_transaction_hash=web3_transaction_hash, ) user = await self._unit_of_work.user_repository.get(user_id) 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() if not email: - raise ApplicationException(status_code=400, message='User email missing') + raise ReceiptDataException(message='User email missing') customer_info = ' '.join( part for part in ( @@ -40,28 +40,28 @@ class CreateCryptoTransferCompletedCommand: if part ) 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() 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: - 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' order = await self._unit_of_work.order_repository.get_by_id(order_id) 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: - 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: - 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')) service_fee = Decimal(str(order.service_fee)).quantize(Decimal('0.01')) principal_amount = (total_amount - service_fee).quantize(Decimal('0.01')) if principal_amount < 0: - raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative') + raise ReceiptDataException(message='Invalid receipt amounts: principal negative') try: receipt_response = await self._receipt.create_receipt( diff --git a/src/application/commands/create_order_command.py b/src/application/commands/create_order_command.py index e13ea38..8544112 100644 --- a/src/application/commands/create_order_command.py +++ b/src/application/commands/create_order_command.py @@ -7,7 +7,7 @@ from src.application.contracts import ILogger from src.application.contracts import IItPayService from src.application.domain.entities.order import OrderEntity 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.infrastructure.database.decorators import transactional from src.presentation.schemas.order import CreateOrder @@ -40,7 +40,7 @@ class CreateOrderCommand: actual_total_price = quote.total_price if actual_total_price > payment_data.total_price * Decimal('1.01'): 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( user_id=user_id, diff --git a/src/application/commands/create_payment_command.py b/src/application/commands/create_payment_command.py index 1acf81f..bbfd79c 100644 --- a/src/application/commands/create_payment_command.py +++ b/src/application/commands/create_payment_command.py @@ -3,7 +3,7 @@ from ulid import ULID from src.application.abstractions import IUnitOfWork from src.application.contracts import ILogger,IQueueMessanger 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.database.decorators import transactional 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()) self._logger.set_trace_id(trace_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: - 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( user_id=user_id, order_id=order_id, diff --git a/src/application/domain/exceptions/__init__.py b/src/application/domain/exceptions/__init__.py index c773402..ba34044 100644 --- a/src/application/domain/exceptions/__init__.py +++ b/src/application/domain/exceptions/__init__.py @@ -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_request_exception import BadRequestException 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.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.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.unauthorized_exception import UnauthorizedException @@ -13,9 +21,17 @@ __all__ = [ 'BadGatewayException', 'BadRequestException', 'ConflictException', + 'CsrfException', 'ForbiddenException', 'InternalServerException', + 'JwtException', 'NotFoundException', + 'PaymentMetadataException', + 'PaymentProviderException', + 'PriceChangedException', + 'ReceiptDataException', + 'ReceiptProviderException', + 'RateLimitException', 'ServiceUnavailableException', 'UnauthorizedException', ] \ No newline at end of file diff --git a/src/application/domain/exceptions/csrf_exception.py b/src/application/domain/exceptions/csrf_exception.py new file mode 100644 index 0000000..61b3067 --- /dev/null +++ b/src/application/domain/exceptions/csrf_exception.py @@ -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) diff --git a/src/application/domain/exceptions/jwt_exception.py b/src/application/domain/exceptions/jwt_exception.py new file mode 100644 index 0000000..312b1c6 --- /dev/null +++ b/src/application/domain/exceptions/jwt_exception.py @@ -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) diff --git a/src/application/domain/exceptions/payment_metadata_exception.py b/src/application/domain/exceptions/payment_metadata_exception.py new file mode 100644 index 0000000..d7359b7 --- /dev/null +++ b/src/application/domain/exceptions/payment_metadata_exception.py @@ -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) diff --git a/src/application/domain/exceptions/payment_provider_exception.py b/src/application/domain/exceptions/payment_provider_exception.py new file mode 100644 index 0000000..9927eaa --- /dev/null +++ b/src/application/domain/exceptions/payment_provider_exception.py @@ -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) diff --git a/src/application/domain/exceptions/price_changed_exception.py b/src/application/domain/exceptions/price_changed_exception.py new file mode 100644 index 0000000..2a6baed --- /dev/null +++ b/src/application/domain/exceptions/price_changed_exception.py @@ -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) diff --git a/src/application/domain/exceptions/rate_limit_exception.py b/src/application/domain/exceptions/rate_limit_exception.py new file mode 100644 index 0000000..12ea8ce --- /dev/null +++ b/src/application/domain/exceptions/rate_limit_exception.py @@ -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) diff --git a/src/application/domain/exceptions/receipt_data_exception.py b/src/application/domain/exceptions/receipt_data_exception.py new file mode 100644 index 0000000..672af47 --- /dev/null +++ b/src/application/domain/exceptions/receipt_data_exception.py @@ -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) diff --git a/src/application/domain/exceptions/receipt_provider_exception.py b/src/application/domain/exceptions/receipt_provider_exception.py new file mode 100644 index 0000000..6da9385 --- /dev/null +++ b/src/application/domain/exceptions/receipt_provider_exception.py @@ -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) diff --git a/src/infrastructure/cloud_kassir/client.py b/src/infrastructure/cloud_kassir/client.py index 45cc9aa..a25fc16 100644 --- a/src/infrastructure/cloud_kassir/client.py +++ b/src/infrastructure/cloud_kassir/client.py @@ -7,7 +7,7 @@ import aiohttp import orjson from aiohttp import BasicAuth, ClientTimeout 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 @@ -122,30 +122,30 @@ class ClaudeKassirClient(IReceipt): async with session.post(url, json=payload, headers=headers, auth=auth) as resp: raw = (await resp.text()).strip() if not raw: - raise ApplicationException( - status_code=502, + raise ReceiptProviderException( message=f'Receipt provider empty response (HTTP {resp.status})', ) try: parsed: Any = orjson.loads(raw) except orjson.JSONDecodeError: preview = raw[:240].replace('\n', ' ') - raise ApplicationException( - status_code=502, + raise ReceiptProviderException( message=f'Receipt provider non-JSON response (HTTP {resp.status}): {preview}', ) if not isinstance(parsed, dict): - raise ApplicationException(status_code=502, message='Receipt provider invalid response') + raise ReceiptProviderException(message='Receipt provider invalid response') body = parsed if resp.status >= 400: - raise ApplicationException( - status_code=502, + raise ReceiptProviderException( message=str(body.get('Message') or 'Receipt provider error'), ) 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 except ApplicationException: raise except aiohttp.ClientError: - raise ApplicationException(status_code=502, message='Receipt provider unreachable') + raise ReceiptProviderException(message='Receipt provider unreachable') diff --git a/src/infrastructure/itpay/client.py b/src/infrastructure/itpay/client.py index 84e5fd7..7b52f31 100644 --- a/src/infrastructure/itpay/client.py +++ b/src/infrastructure/itpay/client.py @@ -8,7 +8,7 @@ from aiohttp import BasicAuth, ClientTimeout from src.application.contracts.i_itpay_service import IItPayService from src.application.domain.entities.order import OrderEntity 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): @@ -68,7 +68,7 @@ class ItPayClient(IItPayService): except orjson.JSONDecodeError: response_json = {'raw': response_text} 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 = body_raw if isinstance(body_raw, dict) else response_json @@ -112,4 +112,4 @@ class ItPayClient(IItPayService): except ApplicationException: raise except aiohttp.ClientError: - raise ApplicationException(status_code=502, message='Payment provider unreachable') + raise PaymentProviderException(message='Payment provider unreachable') diff --git a/src/infrastructure/security/csrf.py b/src/infrastructure/security/csrf.py index 1b6d3fd..a69dc54 100644 --- a/src/infrastructure/security/csrf.py +++ b/src/infrastructure/security/csrf.py @@ -3,7 +3,7 @@ import secrets from typing import Any, Optional, Mapping from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature 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 @@ -42,21 +42,12 @@ class CsrfService(ICsrfService): try: data = self._serializer.loads(token, max_age=self.TTL_SECONDS) except SignatureExpired: - raise ApplicationException( - status_code=403, - message='CSRF token expired', - ) + raise CsrfException(message='CSRF token expired') except BadSignature: - raise ApplicationException( - status_code=403, - message='CSRF token invalid', - ) + raise CsrfException(message='CSRF token invalid') if expected_subject is not None and data.get('sub') != expected_subject: - raise ApplicationException( - status_code=403, - message='CSRF token subject mismatch', - ) + raise CsrfException(message='CSRF token subject mismatch') 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: if not cookie_token or not header_token: - raise ApplicationException( - status_code=403, - message='CSRF token missing', - ) + raise CsrfException(message='CSRF token missing') if not secrets.compare_digest(cookie_token, header_token): - raise ApplicationException( - status_code=403, - message='CSRF token mismatch', - ) + raise CsrfException(message='CSRF token mismatch') self.verify(cookie_token, expected_subject=expected_subject) diff --git a/src/infrastructure/security/jwt.py b/src/infrastructure/security/jwt.py index 4274902..e32a17e 100644 --- a/src/infrastructure/security/jwt.py +++ b/src/infrastructure/security/jwt.py @@ -2,7 +2,7 @@ from __future__ import annotations from jose import jwt, ExpiredSignatureError, JWTError from src.application.contracts import ILogger, IJwtService 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.vault import JwtKeyStore @@ -17,7 +17,7 @@ class JwtService(IJwtService): if payload.get('type') != 'access': 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: return AccessTokenPayload( @@ -32,7 +32,7 @@ class JwtService(IJwtService): ) except KeyError as 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: kid: str | None = None @@ -42,12 +42,12 @@ class JwtService(IJwtService): kid = header.get('kid') if not kid: 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') if received_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)) @@ -58,7 +58,7 @@ class JwtService(IJwtService): if not public_pem: 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 = { 'verify_signature': True, @@ -85,25 +85,25 @@ class JwtService(IJwtService): if 'sid' not in payload: 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: 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 except ExpiredSignatureError as 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: raise except JWTError as 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: self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}') - raise ApplicationException(status_code=500, message='JWT decode failed') \ No newline at end of file + raise InternalServerException(message='JWT decode failed') \ No newline at end of file diff --git a/src/infrastructure/vault/keys.py b/src/infrastructure/vault/keys.py index fefb0b1..ccafa17 100644 --- a/src/infrastructure/vault/keys.py +++ b/src/infrastructure/vault/keys.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio from datetime import datetime, timezone 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 @@ -52,7 +52,7 @@ class JwtKeyStore: @classmethod def get_instance(cls) -> 'JwtKeyStore': if cls._instance is None: - raise ApplicationException(status_code=500, message='JwtKeyStore not initialized') + raise InternalServerException(message='JwtKeyStore not initialized') return cls._instance def _read_keyset_sync(self) -> JwtPublicKeySet: diff --git a/src/presentation/decorators/auth.py b/src/presentation/decorators/auth.py index ba8b030..b1ea930 100644 --- a/src/presentation/decorators/auth.py +++ b/src/presentation/decorators/auth.py @@ -1,7 +1,7 @@ from fastapi import Depends, Request from fastapi.security.utils import get_authorization_scheme_param 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.presentation.dependencies import get_jwt_service @@ -27,10 +27,10 @@ async def require_access_token( ) -> AuthContext: token = _extract_access_token(request) 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) 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) diff --git a/src/presentation/decorators/csrf.py b/src/presentation/decorators/csrf.py index 768e69e..4351ffd 100644 --- a/src/presentation/decorators/csrf.py +++ b/src/presentation/decorators/csrf.py @@ -3,7 +3,7 @@ import inspect from functools import wraps from typing import Callable, Awaitable, Any, Optional, Annotated 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 @@ -39,10 +39,7 @@ def csrf_protect( break if request is None: - raise ApplicationException( - status_code=500, - message='Request is required for CSRF protection', - ) + raise InternalServerException(message='Request is required for CSRF protection') csrf = CsrfService() diff --git a/src/presentation/decorators/rate_limit.py b/src/presentation/decorators/rate_limit.py index 6ff0094..858bf48 100644 --- a/src/presentation/decorators/rate_limit.py +++ b/src/presentation/decorators/rate_limit.py @@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtim from fastapi import Request from redis.asyncio.client import Redis 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.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] except Exception as 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 method = request.method @@ -153,13 +153,12 @@ def rate_limit( logger.warning(f'RateLimit fail-open activated key={redis_key}') return await func(*args, **kwargs) - raise ApplicationException(503, 'Rate limiter unavailable') + raise RateLimitException(status_code=503,message='Rate limiter unavailable') if count > limit: retry_after = max(ttl, 0) logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}') - raise ApplicationException( - status_code=429, + raise RateLimitException( message='Too Many Requests', headers={'Retry-After': str(retry_after)}, ) diff --git a/src/presentation/handler/application_exception_handler.py b/src/presentation/handler/application_exception_handler.py index b1bb593..d8c3daf 100644 --- a/src/presentation/handler/application_exception_handler.py +++ b/src/presentation/handler/application_exception_handler.py @@ -1,9 +1,16 @@ from fastapi import Request from fastapi.responses import ORJSONResponse 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 if 500 <= exc.status_code: detail = 'Internal Server Error' diff --git a/src/presentation/routing/order.py b/src/presentation/routing/order.py index 43b43e2..e93016a 100644 --- a/src/presentation/routing/order.py +++ b/src/presentation/routing/order.py @@ -26,6 +26,19 @@ orders_router = APIRouter(tags=['orders']) payment_router = APIRouter(prefix='/payment', 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: return PaymentConfigResponse( @@ -156,16 +169,7 @@ async def _websocket_auth_context(websocket: WebSocket, jwt_service: IJwtService '/create', response_model=CreateOrderResponse, status_code=201, - 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'}, - }, + responses=ERROR_RESPONSES, ) @csrf_protect() async def create_order( @@ -200,7 +204,12 @@ async def create_order( 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( command: GetPaymentConfigCommand = Depends(get_payment_config_command), ) -> PaymentConfigResponse: @@ -208,7 +217,12 @@ async def payment_config( 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( usdt_amount: Decimal = Query(gt=0, decimal_places=2, max_digits=20), command: GetPaymentQuoteCommand = Depends(get_payment_quote_command), @@ -217,7 +231,12 @@ async def payment_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( limit: int = Query(default=20, ge=1, le=100), 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) -@payments_router.get('/payments', response_model=PaymentsResponse) +@payments_router.get( + '/payments', + response_model=PaymentsResponse, + status_code=200, + responses=ERROR_RESPONSES, +) async def list_payments( limit: int = Query(default=20, ge=1, le=100), 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( order_id: str, auth: AuthContext = Depends(require_access_token), @@ -292,7 +321,12 @@ async def order_events( 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( order_id: str, auth: AuthContext = Depends(require_access_token), @@ -302,7 +336,11 @@ async def order_detail( 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( request: Request, payment_command: CreatePaymentCommand = Depends(get_create_payment_command),