feat: add endpoints desc
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
12
src/application/domain/exceptions/csrf_exception.py
Normal file
12
src/application/domain/exceptions/csrf_exception.py
Normal 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)
|
||||||
13
src/application/domain/exceptions/jwt_exception.py
Normal file
13
src/application/domain/exceptions/jwt_exception.py
Normal 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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
12
src/application/domain/exceptions/price_changed_exception.py
Normal file
12
src/application/domain/exceptions/price_changed_exception.py
Normal 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)
|
||||||
13
src/application/domain/exceptions/rate_limit_exception.py
Normal file
13
src/application/domain/exceptions/rate_limit_exception.py
Normal 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)
|
||||||
12
src/application/domain/exceptions/receipt_data_exception.py
Normal file
12
src/application/domain/exceptions/receipt_data_exception.py
Normal 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)
|
||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user