feat: add custom exceptions

This commit is contained in:
2026-05-09 14:49:15 +03:00
parent e929133db8
commit 499947e44e
17 changed files with 204 additions and 48 deletions

View File

@@ -1 +1,21 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException from src.application.domain.exceptions.application_exception import ApplicationException
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.forbidden_exception import ForbiddenException
from src.application.domain.exceptions.internal_server_exception import InternalServerException
from src.application.domain.exceptions.not_found_exception import NotFoundException
from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException
from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException
__all__ = [
'ApplicationException',
'BadGatewayException',
'BadRequestException',
'ConflictException',
'ForbiddenException',
'InternalServerException',
'NotFoundException',
'ServiceUnavailableException',
'UnauthorizedException',
]

View File

@@ -7,12 +7,13 @@ class ApplicationException(Exception):
self, self,
status_code: int, status_code: int,
message: str, message: str,
headers: Mapping[str, str] | None = None, headers: Mapping[str,str] | None = None,
): ):
super().__init__(message) super().__init__(message)
self.status_code = status_code self.status_code = status_code
self.message = message self.message = message
self.headers = headers self.headers = headers
def __str__(self): def __str__(self):
return f'{self.status_code}: {self.message}' return f'{self.status_code}: {self.message}'

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class BadGatewayException(ApplicationException):
def __init__(
self,
message: str = 'Bad Gateway',
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 BadRequestException(ApplicationException):
def __init__(
self,
message: str = 'Bad Request',
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 ConflictException(ApplicationException):
def __init__(
self,
message: str = 'Conflict',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=409,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 ForbiddenException(ApplicationException):
def __init__(
self,
message: str = 'Forbidden',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=403,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 InternalServerException(ApplicationException):
def __init__(
self,
message: str = 'Internal Server Error',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=500,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 NotFoundException(ApplicationException):
def __init__(
self,
message: str = 'Not Found',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=404,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 ServiceUnavailableException(ApplicationException):
def __init__(
self,
message: str = 'Service Unavailable',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=503,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 UnauthorizedException(ApplicationException):
def __init__(
self,
message: str = 'Unauthorized',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=401,message=message,headers=headers)

View File

@@ -2,18 +2,18 @@ from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import secrets import secrets
from typing import AsyncGenerator from typing import AsyncGenerator
from fastapi import Depends, FastAPI, status from fastapi import Depends, FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException,UnauthorizedException
from src.infrastructure.cache import create_redis_client from src.infrastructure.cache import create_redis_client
from src.infrastructure.config.settings import get_settings from src.infrastructure.config.settings import get_settings
from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
from src.infrastructure.utils import generate_instance_id from src.infrastructure.utils import generate_instance_id
from src.infrastructure.logger import logger from src.infrastructure.logger import logger
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.presentation.handlers import application_exception_handler, unhandled_exception_handler from src.presentation.handler import application_exception_handler, unhandled_exception_handler
from src.presentation.messaging import crypto_transfer_router from src.presentation.messaging import crypto_transfer_router
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
from src.presentation.routing import order_router from src.presentation.routing import order_router
@@ -25,8 +25,7 @@ async def verify_credentials(credentials: HTTPBasicCredentials = Depends(securit
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME) user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD) pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD)
if not (user_ok and pass_ok): if not (user_ok and pass_ok):
raise ApplicationException( raise UnauthorizedException(
status_code=status.HTTP_401_UNAUTHORIZED,
message='Unauthorized', message='Unauthorized',
headers={'WWW-Authenticate': 'Basic'}, headers={'WWW-Authenticate': 'Basic'},
) )

View File

@@ -0,0 +1,7 @@
from src.presentation.handler.application_exception_handler import application_exception_handler
from src.presentation.handler.unhandled_exception_handler import unhandled_exception_handler
__all__ = [
'application_exception_handler',
'unhandled_exception_handler',
]

View File

@@ -1,17 +1,15 @@
from fastapi.responses import ORJSONResponse
from fastapi import Request from fastapi import Request
from fastapi.responses import ORJSONResponse
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse: async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse:
detail = exc.message detail = exc.message
if 500 <= exc.status_code: if 500 <= exc.status_code:
detail = "Internal Server Error" detail = 'Internal Server Error'
return ORJSONResponse( return ORJSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={'detail': detail, 'status_code': exc.status_code}, content={'detail': detail,'status_code': exc.status_code},
headers=dict(exc.headers) if exc.headers else None, headers=dict(exc.headers) if exc.headers else None,
) )

View File

@@ -1,5 +1,5 @@
from fastapi.responses import ORJSONResponse
from fastapi import Request from fastapi import Request
from fastapi.responses import ORJSONResponse
from starlette import status from starlette import status
from src.infrastructure.logger import logger from src.infrastructure.logger import logger
@@ -8,5 +8,5 @@ async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJS
logger.exception(f'Unhandled exception: {type(exc).__name__}') logger.exception(f'Unhandled exception: {type(exc).__name__}')
return ORJSONResponse( return ORJSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'detail': 'Internal Server Error'}, content={'detail': 'Internal Server Error','status_code': status.HTTP_500_INTERNAL_SERVER_ERROR},
) )

View File

@@ -1,2 +0,0 @@
from src.presentation.handlers.unhandled_handler import unhandled_exception_handler
from src.presentation.handlers.application_handler import application_exception_handler

View File

@@ -1,6 +1,6 @@
from urllib.parse import parse_qs from urllib.parse import parse_qs
import orjson import orjson
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse
from src.application.commands import CreateOrderCommand from src.application.commands import CreateOrderCommand
from src.application.commands import CreatePaymentCloudkassirCommand from src.application.commands import CreatePaymentCloudkassirCommand
@@ -10,20 +10,26 @@ from src.application.domain.enums import OrderStatus
from src.presentation.decorators import require_access_token, csrf_protect from src.presentation.decorators import require_access_token, csrf_protect
from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_cloudkassir_command from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_cloudkassir_command
from src.presentation.dependencies.logger import get_logger from src.presentation.dependencies.logger import get_logger
from src.presentation.schemas.order import CreateOrder from src.presentation.schemas.order import CreateOrder,CreateOrderResponse,OrderPaymentResponse
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
order_router = APIRouter(prefix='/order', tags=['orders']) order_router = APIRouter(prefix='/order', tags=['orders'])
@order_router.post('/create') @order_router.post(
'/create',
response_model=CreateOrderResponse,
status_code=201,
responses={409: {'model': CreateOrderResponse}},
)
#@csrf_protect() #@csrf_protect()
async def create_order( async def create_order(
payment_data: CreateOrder, payment_data: CreateOrder,
response: Response,
#auth: AuthContext = Depends(require_access_token), #auth: AuthContext = Depends(require_access_token),
command: CreateOrderCommand = Depends(get_create_order_command), command: CreateOrderCommand = Depends(get_create_order_command),
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
) -> ORJSONResponse: ) -> CreateOrderResponse:
#o = await command(payment_data, auth.user_id) #o = await command(payment_data, auth.user_id)
o = await command(payment_data, '01KPKAFN6J1NJBY15DX8JE2QYB') o = await command(payment_data, '01KPKAFN6J1NJBY15DX8JE2QYB')
itpay_error = o.status in ( itpay_error = o.status in (
@@ -32,32 +38,33 @@ async def create_order(
OrderStatus.ERROR, OrderStatus.ERROR,
) )
http_code = 409 if itpay_error else 201 http_code = 409 if itpay_error else 201
content: dict = { response.status_code = http_code
'status_code': http_code, content = CreateOrderResponse(
'order': { status_code=http_code,
'id': o.id, order=OrderPaymentResponse(
'created_at': o.created_at.isoformat() if o.created_at is not None else None, id=o.id,
'updated_at': o.updated_at.isoformat() if o.updated_at is not None else None, created_at=o.created_at.isoformat() if o.created_at is not None else None,
'user_id': o.user_id, updated_at=o.updated_at.isoformat() if o.updated_at is not None else None,
'usdt_amount': str(o.usdt_amount) if o.usdt_amount is not None else None, user_id=o.user_id,
'usdt_exchange_rate': str(o.usdt_exchange_rate) if o.usdt_exchange_rate is not None else None, usdt_amount=str(o.usdt_amount) if o.usdt_amount is not None else None,
'gas_fee': str(o.gas_fee) if o.gas_fee is not None else None, usdt_exchange_rate=str(o.usdt_exchange_rate) if o.usdt_exchange_rate is not None else None,
'total_price': str(o.total_price) if o.total_price is not None else None, gas_fee=str(o.gas_fee) if o.gas_fee is not None else None,
'service_fee': str(o.service_fee) if o.service_fee is not None else None, total_price=str(o.total_price) if o.total_price is not None else None,
'status': o.status.value if o.status is not None else None, service_fee=str(o.service_fee) if o.service_fee is not None else None,
'client_payment_id': o.client_payment_id, status=o.status,
'itpay_payment_qr_url_desktop': o.itpay_payment_qr_url_desktop, client_payment_id=o.client_payment_id,
'itpay_payment_qr_url_android': o.itpay_payment_qr_url_android, itpay_payment_qr_url_desktop=o.itpay_payment_qr_url_desktop,
'itpay_payment_qr_url_ios': o.itpay_payment_qr_url_ios, itpay_payment_qr_url_android=o.itpay_payment_qr_url_android,
'itpay_payment_qr_image_desktop': o.itpay_payment_qr_image_desktop, itpay_payment_qr_url_ios=o.itpay_payment_qr_url_ios,
'itpay_payment_qr_image_android': o.itpay_payment_qr_image_android, itpay_payment_qr_image_desktop=o.itpay_payment_qr_image_desktop,
'itpay_payment_qr_image_ios': o.itpay_payment_qr_image_ios, itpay_payment_qr_image_android=o.itpay_payment_qr_image_android,
'itpay_id': o.itpay_id, itpay_payment_qr_image_ios=o.itpay_payment_qr_image_ios,
'itpay_qr_id': o.itpay_qr_id, itpay_id=o.itpay_id,
'itpay_amount': str(o.itpay_amount) if o.itpay_amount is not None else None, itpay_qr_id=o.itpay_qr_id,
'itpay_created_at': o.itpay_created_at.isoformat() if o.itpay_created_at is not None else None, itpay_amount=str(o.itpay_amount) if o.itpay_amount is not None else None,
} itpay_created_at=o.itpay_created_at.isoformat() if o.itpay_created_at is not None else None,
} ),
)
log_ids = { log_ids = {
'event': 'order_create_itpay_failed' if itpay_error else 'order_created', 'event': 'order_create_itpay_failed' if itpay_error else 'order_created',
'order_id': o.id, 'order_id': o.id,
@@ -67,7 +74,7 @@ async def create_order(
'order_status': o.status.value if o.status is not None else None, 'order_status': o.status.value if o.status is not None else None,
} }
logger.info(log_ids) logger.info(log_ids)
return ORJSONResponse(content=content, status_code=http_code) return content
@order_router.post('/webhook/itpay') @order_router.post('/webhook/itpay')

View File

@@ -1,5 +1,6 @@
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel,Field from pydantic import BaseModel,Field
from src.application.domain.enums import OrderStatus
class CreateOrder(BaseModel): class CreateOrder(BaseModel):
@@ -8,3 +9,32 @@ class CreateOrder(BaseModel):
gas_fee: Decimal = Field(gt=0, decimal_places=2, max_digits=20) gas_fee: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
total_price: Decimal = Field(gt=0, decimal_places=2, max_digits=20) total_price: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
class OrderPaymentResponse(BaseModel):
id: str | None = None
created_at: str | None = None
updated_at: str | None = None
user_id: str | None = None
usdt_amount: str | None = None
usdt_exchange_rate: str | None = None
gas_fee: str | None = None
total_price: str | None = None
service_fee: str | None = None
status: OrderStatus | None = None
client_payment_id: str | None = None
itpay_payment_qr_url_desktop: str | None = None
itpay_payment_qr_url_android: str | None = None
itpay_payment_qr_url_ios: str | None = None
itpay_payment_qr_image_desktop: str | None = None
itpay_payment_qr_image_android: str | None = None
itpay_payment_qr_image_ios: str | None = None
itpay_id: str | None = None
itpay_qr_id: str | None = None
itpay_amount: str | None = None
itpay_created_at: str | None = None
class CreateOrderResponse(BaseModel):
status_code: int
order: OrderPaymentResponse