diff --git a/src/application/domain/exceptions/__init__.py b/src/application/domain/exceptions/__init__.py index 6d6ca18..03368ee 100644 --- a/src/application/domain/exceptions/__init__.py +++ b/src/application/domain/exceptions/__init__.py @@ -1 +1,11 @@ -from src.application.domain.exceptions.application_exceptions import ApplicationException \ No newline at end of file +from src.application.domain.exceptions.application_exceptions import ( + ApplicationException, + BadRequestException, + ConflictException, + ForbiddenException, + InternalException, + NotFoundException, + ServiceUnavailableException, + TooManyRequestsException, + UnauthorizedException, +) diff --git a/src/application/domain/exceptions/application_exceptions.py b/src/application/domain/exceptions/application_exceptions.py index 5006dee..03cecd3 100644 --- a/src/application/domain/exceptions/application_exceptions.py +++ b/src/application/domain/exceptions/application_exceptions.py @@ -1,4 +1,5 @@ from __future__ import annotations + from typing import Mapping @@ -14,5 +15,45 @@ class ApplicationException(Exception): self.message = message self.headers = headers - def __str__(self): - return f"{self.status_code}: {self.message}" + def __str__(self) -> str: + return f'{self.status_code}: {self.message}' + + +class BadRequestException(ApplicationException): + def __init__(self, message: str, headers: Mapping[str, str] | None = None): + super().__init__(400, message, headers) + + +class UnauthorizedException(ApplicationException): + def __init__(self, message: str = 'Unauthorized', headers: Mapping[str, str] | None = None): + super().__init__(401, message, headers) + + +class ForbiddenException(ApplicationException): + def __init__(self, message: str = 'Forbidden', headers: Mapping[str, str] | None = None): + super().__init__(403, message, headers) + + +class NotFoundException(ApplicationException): + def __init__(self, message: str = 'Not found', headers: Mapping[str, str] | None = None): + super().__init__(404, message, headers) + + +class ConflictException(ApplicationException): + def __init__(self, message: str, headers: Mapping[str, str] | None = None): + super().__init__(409, message, headers) + + +class TooManyRequestsException(ApplicationException): + def __init__(self, message: str, headers: Mapping[str, str] | None = None): + super().__init__(429, message, headers) + + +class ServiceUnavailableException(ApplicationException): + def __init__(self, message: str, headers: Mapping[str, str] | None = None): + super().__init__(503, message, headers) + + +class InternalException(ApplicationException): + def __init__(self, message: str = 'Internal Server Error', headers: Mapping[str, str] | None = None): + super().__init__(500, message, headers) diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index 6f8c4ee..d87848f 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -1,10 +1,9 @@ from __future__ import annotations -from fastapi import status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import SQLAlchemyError from src.application.contracts import ILogger -from src.application.domain.exceptions import ApplicationException +from src.application.domain.exceptions import ApplicationException, BadRequestException, InternalException, NotFoundException from src.application.abstractions.repositories import IUserRepository from src.application.domain.entities import UserEntity from src.infrastructure.database.models import UserModel @@ -27,7 +26,7 @@ class UserRepository(IUserRepository): user: UserModel | None = result.scalar_one_or_none() if user is None: self._logger.warning(f'User not found with user_id {user_id}') - raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='User not found') + raise NotFoundException(message='User not found') return user @staticmethod @@ -60,7 +59,7 @@ class UserRepository(IUserRepository): raise except SQLAlchemyError as exception: self._logger.exception(str(exception)) - raise ApplicationException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=f'Database error: {str(exception)}') + raise InternalException(message=f'Database error: {str(exception)}') async def _update_field(self, user_id: str, **fields: object) -> UserEntity: try: @@ -74,7 +73,7 @@ class UserRepository(IUserRepository): raise except SQLAlchemyError as exception: self._logger.exception(str(exception)) - raise ApplicationException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=f'Database error: {str(exception)}') + raise InternalException(message=f'Database error: {str(exception)}') async def set_phone(self, user_id: str, phone: str) -> UserEntity: return await self._update_field(user_id, phone=phone) @@ -83,10 +82,7 @@ class UserRepository(IUserRepository): allowed = {'passport_data', 'inn', 'erc20'} payload = {k: v for k, v in fields.items() if k in allowed and v is not None} if not payload: - raise ApplicationException( - status_code=status.HTTP_400_BAD_REQUEST, - message='No identity fields to update', - ) + raise BadRequestException(message='No identity fields to update') return await self._update_field(user_id, **payload) async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity: @@ -100,7 +96,7 @@ class UserRepository(IUserRepository): raise except SQLAlchemyError as exception: self._logger.exception(str(exception)) - raise ApplicationException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=f'Database error: {str(exception)}') + raise InternalException(message=f'Database error: {str(exception)}') async def set_password(self, user_id: str, password_hash: str) -> UserEntity: return await self._update_field(user_id, password_hash=password_hash) @@ -121,4 +117,4 @@ class UserRepository(IUserRepository): return result.scalar_one_or_none() is not None except SQLAlchemyError as exception: self._logger.exception(str(exception)) - raise ApplicationException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=f'Database error: {str(exception)}') + raise InternalException(message=f'Database error: {str(exception)}') diff --git a/src/main.py b/src/main.py index 04681ce..6cf14ed 100644 --- a/src/main.py +++ b/src/main.py @@ -2,18 +2,25 @@ from __future__ import annotations from contextlib import asynccontextmanager import secrets 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.responses import HTMLResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from starlette.middleware.cors import CORSMiddleware -from src.application.domain.exceptions import ApplicationException +from starlette.exceptions import HTTPException +from fastapi.exceptions import RequestValidationError +from src.application.domain.exceptions import ApplicationException, UnauthorizedException from src.infrastructure.cache import create_redis_client from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler from src.infrastructure.utils import generate_instance_id from src.infrastructure.logger import logger from src.infrastructure.config import settings -from src.presentation.handlers import application_exception_handler, unhandled_exception_handler +from src.presentation.handlers import ( + application_exception_handler, + http_exception_handler, + unhandled_exception_handler, + validation_exception_handler, +) from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware from src.presentation.routing import me_router @@ -24,8 +31,7 @@ async def verify_credentials(credentials: HTTPBasicCredentials = Depends(securit user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME) pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD) if not (user_ok and pass_ok): - raise ApplicationException( - status_code=status.HTTP_401_UNAUTHORIZED, + raise UnauthorizedException( message='Unauthorized', headers={'WWW-Authenticate': 'Basic'}, ) @@ -78,6 +84,8 @@ app: FastAPI = FastAPI( }, ) +app.add_exception_handler(RequestValidationError, validation_exception_handler) +app.add_exception_handler(HTTPException, http_exception_handler) app.add_exception_handler(ApplicationException, application_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler) 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/handlers/__init__.py b/src/presentation/handlers/__init__.py index cb6cbad..e9ca9c5 100644 --- a/src/presentation/handlers/__init__.py +++ b/src/presentation/handlers/__init__.py @@ -1,2 +1,4 @@ from src.presentation.handlers.unhandled_handler import unhandled_exception_handler -from src.presentation.handlers.application_handler import application_exception_handler \ No newline at end of file +from src.presentation.handlers.application_handler import application_exception_handler +from src.presentation.handlers.http_exception_handler import http_exception_handler +from src.presentation.handlers.validation_handler import validation_exception_handler diff --git a/src/presentation/handlers/http_exception_handler.py b/src/presentation/handlers/http_exception_handler.py new file mode 100644 index 0000000..84510d2 --- /dev/null +++ b/src/presentation/handlers/http_exception_handler.py @@ -0,0 +1,11 @@ +from fastapi import Request +from fastapi.responses import ORJSONResponse +from starlette.exceptions import HTTPException + + +async def http_exception_handler(_request: Request, exc: HTTPException) -> ORJSONResponse: + return ORJSONResponse( + status_code=exc.status_code, + content={'detail': exc.detail}, + headers=dict(exc.headers) if exc.headers else None, + ) diff --git a/src/presentation/handlers/validation_handler.py b/src/presentation/handlers/validation_handler.py new file mode 100644 index 0000000..155ac5d --- /dev/null +++ b/src/presentation/handlers/validation_handler.py @@ -0,0 +1,10 @@ +from fastapi import Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import ORJSONResponse + + +async def validation_exception_handler(_request: Request, exc: RequestValidationError) -> ORJSONResponse: + return ORJSONResponse( + status_code=422, + content={'detail': exc.errors()}, + ) diff --git a/src/presentation/routing/__init__.py b/src/presentation/routing/__init__.py index b151a36..d58b4d1 100644 --- a/src/presentation/routing/__init__.py +++ b/src/presentation/routing/__init__.py @@ -1,13 +1,12 @@ from fastapi import APIRouter from src.presentation.routing.account import account_router +from src.presentation.routing.account_settings import account_settings_router me_router = APIRouter(prefix='/me', tags=['Account']) me_router.include_router(account_router) - -# from src.presentation.routing.account_settings import account_settings_router -# me_router.include_router(account_settings_router) +me_router.include_router(account_settings_router) # from src.presentation.routing.devices import devices_router # me_devices_router = APIRouter(prefix='/me', tags=['Devices']) diff --git a/src/presentation/schemas/phone.py b/src/presentation/schemas/phone.py index 40b94f7..1a81088 100644 --- a/src/presentation/schemas/phone.py +++ b/src/presentation/schemas/phone.py @@ -1,6 +1,5 @@ import re from pydantic import BaseModel, field_validator -from src.application.domain.exceptions import ApplicationException class SetPhoneRequest(BaseModel): @@ -12,6 +11,6 @@ class SetPhoneRequest(BaseModel): cleaned = re.sub(r'[\s\-\(\)]', '', v) pattern = r'^(\+7|8)\d{10}$' if not re.match(pattern, cleaned): - raise ApplicationException(message='Invalid Russian phone number', status_code=429) + raise ValueError('Invalid Russian phone number') normalized = '+7' + cleaned[-10:] - return normalized \ No newline at end of file + return normalized