feat: add docs and custom exc

This commit is contained in:
2026-05-12 17:24:10 +03:00
parent 85cdf1f720
commit 4c6761d4c4
15 changed files with 242 additions and 56 deletions

View File

@@ -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(

View File

@@ -1 +1,2 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
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

View File

@@ -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):

View File

@@ -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')

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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')
raise JwtDecodeFailedException()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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',
},
},
)

View File

@@ -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())
return result

View File

@@ -0,0 +1 @@
from src.presentation.schemas.error import ErrorDetail,ErrorResponse

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class ErrorDetail(BaseModel):
code: str
message: str
class ErrorResponse(BaseModel):
error: ErrorDetail