diff --git a/src/application/commands/create_kyc_command.py b/src/application/commands/create_kyc_command.py index ff8243f..1895de9 100644 --- a/src/application/commands/create_kyc_command.py +++ b/src/application/commands/create_kyc_command.py @@ -3,7 +3,7 @@ from datetime import datetime,timedelta,timezone from src.application.abstractions import IUnitOfWork from src.application.contracts import IBeorgService,ILogger from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycSessionResponse -from src.application.domain.exceptions import ApplicationException +from src.application.domain.exceptions import ApplicationException,KycFailedException,KycNotCompletedException,KycSessionExpiredException,KycSessionMissingUserTokenException from src.application.services import ensure_adult,extract_personal_data,parse_birth_date @@ -58,11 +58,11 @@ class CompleteKycCommand: async def __call__(self,user_id: str) -> BeorgKycResultResponse: session = await self._get_session(user_id) if not session.user_token: - raise ApplicationException(status_code=409,message='KYC session has no user token') + raise KycSessionMissingUserTokenException() result = await self._beorg_service.get_result(user_token=session.user_token) if result.done_state is None: - raise ApplicationException(status_code=409,message='KYC is not completed yet') + raise KycNotCompletedException() if result.done_state is False: async with self._unit_of_work as unit_of_work: await unit_of_work.kyc_repository.update_session_result( @@ -74,7 +74,7 @@ class CompleteKycCommand: result_data=result.data, error='KYC failed', ) - raise ApplicationException(status_code=400,message='KYC failed') + raise KycFailedException() personal_data = extract_personal_data(result.data) birth_date = parse_birth_date(personal_data.birth_date) @@ -115,7 +115,7 @@ class CompleteKycCommand: client_user_token=session.client_user_token, qr_code=session.qr_code, ) - raise ApplicationException(status_code=404,message='KYC session expired') + raise KycSessionExpiredException() class GetKycSessionCommand: @@ -134,7 +134,7 @@ class GetKycSessionCommand: await unit_of_work.kyc_repository.expire_started_sessions(user_id=user_id,now=now) session = await unit_of_work.kyc_repository.get_latest_session(user_id=user_id) if session is None or session.expires_at is None: - raise ApplicationException(status_code=404,message='KYC session expired') + raise KycSessionExpiredException() expires_in = max(int((session.expires_at - now).total_seconds()),0) return KycSessionResponse( diff --git a/src/application/domain/exceptions/__init__.py b/src/application/domain/exceptions/__init__.py index 6d6ca18..fc5560b 100644 --- a/src/application/domain/exceptions/__init__.py +++ b/src/application/domain/exceptions/__init__.py @@ -1 +1,2 @@ -from src.application.domain.exceptions.application_exceptions import ApplicationException \ No newline at end of file +from src.application.domain.exceptions.application_exceptions import ApplicationException +from src.application.domain.exceptions.kyc_exceptions import BeorgConfigException,BeorgRejectedException,BeorgUnavailableException,CsrfRequestRequiredException,InvalidTokenException,JwtDecodeFailedException,KycAgeRestrictedException,KycBirthDateInvalidException,KycFailedException,KycNotCompletedException,KycPersonalDataIncompleteException,KycSessionExpiredException,KycSessionMissingUserTokenException,NotAuthenticatedException,UnauthorizedException,UserNotFoundException \ No newline at end of file diff --git a/src/application/domain/exceptions/application_exceptions.py b/src/application/domain/exceptions/application_exceptions.py index 7396ceb..12b0aa3 100644 --- a/src/application/domain/exceptions/application_exceptions.py +++ b/src/application/domain/exceptions/application_exceptions.py @@ -7,11 +7,13 @@ class ApplicationException(Exception): self, status_code: int, message: str, + error_code: str = 'application_error', headers: Mapping[str, str] | None = None, ): super().__init__(message) self.status_code = status_code self.message = message + self.error_code = error_code self.headers = headers def __str__(self): diff --git a/src/application/domain/exceptions/kyc_exceptions.py b/src/application/domain/exceptions/kyc_exceptions.py new file mode 100644 index 0000000..57a239a --- /dev/null +++ b/src/application/domain/exceptions/kyc_exceptions.py @@ -0,0 +1,98 @@ +from __future__ import annotations +from src.application.domain.exceptions.application_exceptions import ApplicationException + + +class UnauthorizedException(ApplicationException): + + def __init__(self,message: str = 'Unauthorized') -> None: + super().__init__(status_code=401,message=message,error_code='unauthorized') + + +class NotAuthenticatedException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=401,message='Not authenticated',error_code='not_authenticated') + + +class InvalidTokenException(ApplicationException): + + def __init__(self,message: str = 'Invalid token') -> None: + super().__init__(status_code=401,message=message,error_code='invalid_token') + + +class JwtDecodeFailedException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=500,message='JWT decode failed',error_code='jwt_decode_failed') + + +class CsrfRequestRequiredException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=500,message='Request is required for CSRF protection',error_code='csrf_request_required') + + +class UserNotFoundException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=404,message='User not found',error_code='user_not_found') + + +class KycSessionMissingUserTokenException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=409,message='KYC session has no user token',error_code='kyc_session_missing_user_token') + + +class KycNotCompletedException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=409,message='KYC is not completed yet',error_code='kyc_not_completed') + + +class KycFailedException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=400,message='KYC failed',error_code='kyc_failed') + + +class KycSessionExpiredException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=404,message='KYC session expired',error_code='kyc_session_expired') + + +class KycPersonalDataIncompleteException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=422,message='KYC personal data is incomplete',error_code='kyc_personal_data_incomplete') + + +class KycBirthDateInvalidException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=422,message='KYC birth date has invalid format',error_code='kyc_birth_date_invalid') + + +class KycAgeRestrictedException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=403,message='KYC is unavailable for users under 18',error_code='kyc_age_restricted') + + +class BeorgUnavailableException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=502,message='Beorg service unavailable',error_code='beorg_unavailable') + + +class BeorgRejectedException(ApplicationException): + + def __init__(self,message: str = 'Beorg rejected kyc request') -> None: + super().__init__(status_code=400,message=message,error_code='beorg_rejected') + + +class BeorgConfigException(ApplicationException): + + def __init__(self) -> None: + super().__init__(status_code=500,message='Beorg service is not configured',error_code='beorg_not_configured') diff --git a/src/application/services/kyc_personal_data.py b/src/application/services/kyc_personal_data.py index cfaeadc..9908340 100644 --- a/src/application/services/kyc_personal_data.py +++ b/src/application/services/kyc_personal_data.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import date,datetime from typing import Any from src.application.domain.dto import KycPersonalData -from src.application.domain.exceptions import ApplicationException +from src.application.domain.exceptions import KycAgeRestrictedException,KycBirthDateInvalidException,KycPersonalDataIncompleteException FIELD_ALIASES = { @@ -33,7 +33,7 @@ def extract_personal_data(data: Any) -> KycPersonalData: missing = [field for field in ('first_name','last_name','birth_date') if not values.get(field)] if missing: - raise ApplicationException(status_code=422,message='KYC personal data is incomplete') + raise KycPersonalDataIncompleteException() return KycPersonalData( first_name=values['first_name'], @@ -51,7 +51,7 @@ def ensure_adult(birth_date: date) -> None: except ValueError: adult_from = date(today.year - 18,2,28) if birth_date > adult_from: - raise ApplicationException(status_code=403,message='KYC is unavailable for users under 18') + raise KycAgeRestrictedException() def parse_birth_date(value: str) -> date: @@ -84,4 +84,4 @@ def _parse_date(value: str) -> date: return datetime.strptime(clean,date_format).date() except ValueError: continue - raise ApplicationException(status_code=422,message='KYC birth date has invalid format') + raise KycBirthDateInvalidException() diff --git a/src/infrastructure/beorg/client.py b/src/infrastructure/beorg/client.py index f2c6a40..097c894 100644 --- a/src/infrastructure/beorg/client.py +++ b/src/infrastructure/beorg/client.py @@ -3,7 +3,7 @@ from typing import Any import aiohttp from src.application.contracts import IBeorgService from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse -from src.application.domain.exceptions import ApplicationException +from src.application.domain.exceptions import BeorgConfigException,BeorgRejectedException,BeorgUnavailableException class BeorgService(IBeorgService): @@ -49,11 +49,11 @@ class BeorgService(IBeorgService): data = await response.json(content_type=None) if response.status >= 500: - raise ApplicationException(status_code=502,message='Beorg service unavailable') + raise BeorgUnavailableException() result = BeorgKycCreateResponse.model_validate(data) if not result.status: - raise ApplicationException(status_code=400,message=result.error or 'Beorg rejected kyc request') + raise BeorgRejectedException(result.error or 'Beorg rejected kyc request') return result @@ -73,11 +73,11 @@ class BeorgService(IBeorgService): data = await response.json(content_type=None) if response.status >= 500: - raise ApplicationException(status_code=502,message='Beorg service unavailable') + raise BeorgUnavailableException() return BeorgKycResultResponse.model_validate(data) def _ensure_configured(self) -> None: if not self._project_id or not self._machine_uid or not self._token or not self._process_info: - raise ApplicationException(status_code=500,message='Beorg service is not configured') + raise BeorgConfigException() diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index d560a3c..af406d6 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -4,7 +4,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.application.abstractions.repositories import IUserRepository from src.application.domain.entities import UserEntity -from src.application.domain.exceptions import ApplicationException +from src.application.domain.exceptions import UserNotFoundException from src.infrastructure.database.models.user import UserModel @@ -25,7 +25,7 @@ class UserRepository(IUserRepository): result = await self._session.execute(select(UserModel).where(UserModel.email == email)) user = result.scalar_one_or_none() if user is None: - raise ApplicationException(status_code=404,message='User not found') + raise UserNotFoundException() return self._to_entity(user) @@ -46,7 +46,7 @@ class UserRepository(IUserRepository): ) -> UserEntity: user = await self._session.get(UserModel,user_id) if user is None: - raise ApplicationException(status_code=404,message='User not found') + raise UserNotFoundException() user.first_name = first_name user.last_name = last_name diff --git a/src/infrastructure/security/jwt.py b/src/infrastructure/security/jwt.py index 82178e3..aee570e 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,InvalidTokenException,JwtDecodeFailedException 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 InvalidTokenException('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 InvalidTokenException(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 InvalidTokenException('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 InvalidTokenException('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 InvalidTokenException('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 InvalidTokenException('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 InvalidTokenException('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 InvalidTokenException('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 InvalidTokenException() 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 JwtDecodeFailedException() \ No newline at end of file diff --git a/src/main.py b/src/main.py index e122c35..fbd1e7b 100644 --- a/src/main.py +++ b/src/main.py @@ -3,13 +3,14 @@ from contextlib import asynccontextmanager import secrets from typing import AsyncGenerator from apscheduler.schedulers.asyncio import AsyncIOScheduler -from fastapi import Depends, FastAPI, status +from fastapi import Depends,FastAPI +from fastapi.exceptions import RequestValidationError from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.responses import HTMLResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from src.application.commands import PollKycSessionsCommand from src.application.domain.enums import LogFormat,LogLevel -from src.application.domain.exceptions import ApplicationException +from src.application.domain.exceptions import ApplicationException,UnauthorizedException from src.infrastructure.beorg import BeorgService from src.infrastructure.config.settings import get_settings from src.infrastructure.database.context import async_session_maker @@ -18,7 +19,7 @@ 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,unhandled_exception_handler,validation_exception_handler from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware from src.presentation.routing import kyc_router @@ -29,11 +30,9 @@ 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, - message='Unauthorized', - headers={'WWW-Authenticate': 'Basic'}, - ) + exception = UnauthorizedException() + exception.headers = {'WWW-Authenticate': 'Basic'} + raise exception return credentials @@ -97,6 +96,7 @@ app: FastAPI = FastAPI( ) app.add_exception_handler(ApplicationException, application_exception_handler) +app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler) app.include_router(kyc_router) diff --git a/src/presentation/decorators/auth.py b/src/presentation/decorators/auth.py index ba8b030..6de43cb 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 InvalidTokenException,NotAuthenticatedException 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 NotAuthenticatedException() payload: AccessTokenPayload = await jwt_service.decode_access_token(token) if payload.type != 'access': - raise ApplicationException(status_code=401, message='Invalid token type') + raise InvalidTokenException('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..e682223 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 CsrfRequestRequiredException 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 CsrfRequestRequiredException() csrf = CsrfService() diff --git a/src/presentation/handlers/__init__.py b/src/presentation/handlers/__init__.py index aea169c..2b2e4a2 100644 --- a/src/presentation/handlers/__init__.py +++ b/src/presentation/handlers/__init__.py @@ -1,4 +1,5 @@ from fastapi import Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import ORJSONResponse from src.application.domain.exceptions import ApplicationException @@ -6,13 +7,35 @@ from src.application.domain.exceptions import ApplicationException async def application_exception_handler(request: Request,exception: ApplicationException) -> ORJSONResponse: return ORJSONResponse( status_code=exception.status_code, - content={'detail': exception.message}, + content={ + 'error': { + 'code': exception.error_code, + 'message': exception.message, + }, + }, headers=exception.headers, ) +async def validation_exception_handler(request: Request,exception: RequestValidationError) -> ORJSONResponse: + return ORJSONResponse( + status_code=422, + content={ + 'error': { + 'code': 'request_validation_error', + 'message': 'Request validation error', + }, + }, + ) + + async def unhandled_exception_handler(request: Request,exception: Exception) -> ORJSONResponse: return ORJSONResponse( status_code=500, - content={'detail': 'Internal server error'}, + content={ + 'error': { + 'code': 'internal_server_error', + 'message': 'Internal server error', + }, + }, ) diff --git a/src/presentation/routing/kyc.py b/src/presentation/routing/kyc.py index 948c8ca..554c771 100644 --- a/src/presentation/routing/kyc.py +++ b/src/presentation/routing/kyc.py @@ -1,27 +1,81 @@ from fastapi import APIRouter,Depends -from fastapi.responses import ORJSONResponse from src.application.commands import GetKycSessionCommand,PassKycCommand -from src.application.domain.dto import AuthContext +from src.application.domain.dto import AuthContext,BeorgKycCreateResponse,KycSessionResponse from src.presentation.decorators.auth import require_access_token from src.presentation.dependencies.commands import get_kyc_session_command,get_pass_kyc_command +from src.presentation.schemas import ErrorResponse kyc_router = APIRouter(prefix='/kyc', tags=['Kyc']) +CREATE_KYC_RESPONSES = { + 400: { + 'model': ErrorResponse, + 'description': 'Beorg rejected request. error.code: beorg_rejected', + }, + 401: { + 'model': ErrorResponse, + 'description': 'Authentication error. error.code: not_authenticated, invalid_token', + }, + 500: { + 'model': ErrorResponse, + 'description': 'Configuration or internal error. error.code: beorg_not_configured, internal_server_error', + }, + 502: { + 'model': ErrorResponse, + 'description': 'Beorg is unavailable. error.code: beorg_unavailable', + }, + 422: { + 'model': ErrorResponse, + 'description': 'Request validation error. error.code: request_validation_error', + }, +} -@kyc_router.post('/create') +GET_KYC_SESSION_RESPONSES = { + 401: { + 'model': ErrorResponse, + 'description': 'Authentication error. error.code: not_authenticated, invalid_token', + }, + 404: { + 'model': ErrorResponse, + 'description': 'Active KYC session was not found. error.code: kyc_session_expired', + }, + 500: { + 'model': ErrorResponse, + 'description': 'Internal error. error.code: internal_server_error', + }, + 422: { + 'model': ErrorResponse, + 'description': 'Request validation error. error.code: request_validation_error', + }, +} + + +@kyc_router.post( + '/create', + response_model=BeorgKycCreateResponse, + responses=CREATE_KYC_RESPONSES, + summary='Start KYC session', + description='Creates a Beorg KYC session for one hour and returns link, user token and QR code.', +) async def create_kyc( #auth: AuthContext = Depends(require_access_token), command: PassKycCommand = Depends(get_pass_kyc_command), -) -> ORJSONResponse: +) -> BeorgKycCreateResponse: result = await command(user_id='01KPKAFN6J1NJBY15DX8JE2QYB') - return ORJSONResponse(result.model_dump()) + return result -@kyc_router.get('/session') +@kyc_router.get( + '/session', + response_model=KycSessionResponse, + responses=GET_KYC_SESSION_RESPONSES, + summary='Get KYC session', + description='Returns latest KYC session status, link, QR code and expiration data.', +) async def get_kyc_session( #auth: AuthContext = Depends(require_access_token), command: GetKycSessionCommand = Depends(get_kyc_session_command), -) -> ORJSONResponse: +) -> KycSessionResponse: result = await command(user_id='01KPKAFN6J1NJBY15DX8JE2QYB') - return ORJSONResponse(result.model_dump()) \ No newline at end of file + return result \ No newline at end of file diff --git a/src/presentation/schemas/__init__.py b/src/presentation/schemas/__init__.py index e69de29..aade098 100644 --- a/src/presentation/schemas/__init__.py +++ b/src/presentation/schemas/__init__.py @@ -0,0 +1 @@ +from src.presentation.schemas.error import ErrorDetail,ErrorResponse diff --git a/src/presentation/schemas/error.py b/src/presentation/schemas/error.py new file mode 100644 index 0000000..0b685d9 --- /dev/null +++ b/src/presentation/schemas/error.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class ErrorDetail(BaseModel): + code: str + message: str + + +class ErrorResponse(BaseModel): + error: ErrorDetail