From 0d0dea506a2f431e6ed66b0fad175ba8a938139f Mon Sep 17 00:00:00 2001 From: medniy2000 Date: Thu, 20 Nov 2025 17:17:52 +0200 Subject: [PATCH 1/8] add domain exceptions --- src/app/domain/common/exceptions.py | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/app/domain/common/exceptions.py diff --git a/src/app/domain/common/exceptions.py b/src/app/domain/common/exceptions.py new file mode 100644 index 0000000..1bcd980 --- /dev/null +++ b/src/app/domain/common/exceptions.py @@ -0,0 +1,54 @@ +from typing import List, Optional + + +class AppException(Exception): + """Base exception for all application errors""" + + def __init__( + self, + message: str, + details: Optional[List[dict]] = None, + extra: Optional[dict] = None, + ) -> None: + details_ = details + if details_ is None: + details_ = [] + + extra_ = extra + if extra_ is None: + extra_ = {} + + self.message = message + self.details = details_ + self.extra = extra_ + super().__init__(self.message) + + +class ValidationError(AppException): + """Invalid input or data validation failed""" + + pass + + +class NotFoundError(AppException): + """Resource not found""" + + pass + + +class AlreadyExistsError(AppException): + """Resource already exists""" + + pass + + +class AuthenticationError(AppException): + """Authentication failed""" + + pass + + +class AuthorizationError(AppException): + """Insufficient permissions""" + + pass From a246148a2992f41809b37c9a0cab97fea35ae10d Mon Sep 17 00:00:00 2001 From: medniy2000 Date: Thu, 20 Nov 2025 17:33:27 +0200 Subject: [PATCH 2/8] enable fastapi error_handlers --- src/app/interfaces/api/error_handlers.py | 157 +++++++++++++++++++++++ src/app/interfaces/cli/main.py | 7 + 2 files changed, 164 insertions(+) create mode 100644 src/app/interfaces/api/error_handlers.py diff --git a/src/app/interfaces/api/error_handlers.py b/src/app/interfaces/api/error_handlers.py new file mode 100644 index 0000000..29d0f91 --- /dev/null +++ b/src/app/interfaces/api/error_handlers.py @@ -0,0 +1,157 @@ +import traceback +import uuid +from typing import Any, Dict + +from fastapi import Request, status +from fastapi.exceptions import RequestValidationError, HTTPException +from fastapi.responses import JSONResponse +from loguru import logger + +from src.app.config.settings import settings +from src.app.domain.common.exceptions import ( + ValidationError, + NotFoundError, + AlreadyExistsError, + AuthenticationError, + AuthorizationError, + AppException, +) +from src.app.interfaces.cli.main import app + + +def _gen_error_id() -> str: + return str(uuid.uuid4().hex) + + +def _create_error_resp(exc: AppException, status_code: int) -> JSONResponse: + """Create standardized error response""" + extra_ = exc.extra or {} + content: Dict[str, Any] = { + "error_id": _gen_error_id(), + "message": exc.message, + "details": [], + "traceback": None, + } + if settings.DEBUG: + details = exc.details or [] + content["details"] = details + content["traceback"] = traceback.format_exc() + + kwargs: Dict[str, Any] = {"status_code": status_code, "content": content} + headers = extra_.get("headers", {}) or {} + if headers: + kwargs["headers"] = headers + + return JSONResponse(**kwargs) + + +# ========================================== +# Custom Exceptions +# ========================================== +@app.exception_handler(ValidationError) +async def exception_handler_validation_error(request: Request, exc: ValidationError) -> JSONResponse: + return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_TYPE) + + +@app.exception_handler(NotFoundError) +async def exception_handler_notfound_error(request: Request, exc: NotFoundError) -> JSONResponse: + return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_TYPE) + + +@app.exception_handler(AlreadyExistsError) +async def exception_handler_already_exists_error(request: Request, exc: AlreadyExistsError) -> JSONResponse: + return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_TYPE) + + +@app.exception_handler(AuthenticationError) +async def exception_handler_authentication_error(request: Request, exc: AuthenticationError) -> JSONResponse: + return _create_error_resp(exc, status.HTTP_401_UNAUTHORIZED) + + +@app.exception_handler(AuthorizationError) +async def exception_handler_authorization_error(request: Request, exc: AuthorizationError) -> JSONResponse: + return _create_error_resp(exc, status.HTTP_403_FORBIDDEN) + + +# ========================================== +# FastAPI Exceptions +# ========================================== +@app.exception_handler(RequestValidationError) +async def exception_handler_fastapi_request_validation_error( + request: Request, exc: RequestValidationError +) -> JSONResponse: + error_id = _gen_error_id() + + content: Dict[str, Any] = { + "error_id": error_id, + "message": str(exc), + "details": [], + "traceback": None, + } + + kwargs: Dict[str, Any] = {"status_code": status.HTTP_422_UNPROCESSABLE_TYPE, "content": content} + + return JSONResponse(**kwargs) + + +@app.exception_handler(HTTPException) +async def exception_handler_fastapi_http_exception(request: Request, exc: HTTPException) -> JSONResponse: + error_id = _gen_error_id() + + content: Dict[str, Any] = { + "error_id": error_id, + "message": str(exc.detail), + "details": [], + "traceback": None, + } + if settings.DEBUG and exc.status_code >= 500: + content["traceback"] = traceback.format_exc() + + logger.error( + f"Unhandled exception [ID: {error_id}]", + extra={ + "error_id": error_id, + "path": request.url.path, + "method": request.method, + "exception_type": type(exc).__name__, + "exception_message": str(exc), + }, + exc_info=exc, + ) + + kwargs: Dict[str, Any] = {"status_code": exc.status_code, "content": content} + + return JSONResponse(**kwargs) + + +# ========================================== +# Generic Exceptions +# ========================================== +@app.exception_handler(Exception) +async def exception_handler_unhandled_error(request: Request, exc: Exception) -> JSONResponse: + error_id = _gen_error_id() + logger.error( + f"Unhandled exception [ID: {error_id}]", + extra={ + "error_id": error_id, + "path": request.url.path, + "method": request.method, + "exception_type": type(exc).__name__, + "exception_message": str(exc), + }, + exc_info=exc, + ) + + content: Dict[str, Any] = { + "error_id": error_id, + "message": "An internal error occurred. Please contact support.", + "details": [], + "traceback": None, + } + if settings.DEBUG: + content["message"] = str(exc) + content["traceback"] = traceback.format_exc() + + kwargs: Dict[str, Any] = {"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, "content": content} + + return JSONResponse(**kwargs) diff --git a/src/app/interfaces/cli/main.py b/src/app/interfaces/cli/main.py index 2554e3e..89f2384 100644 --- a/src/app/interfaces/cli/main.py +++ b/src/app/interfaces/cli/main.py @@ -66,4 +66,11 @@ def register_middleware(application: FastAPI) -> None: ) +def register_error_handlers(application: FastAPI) -> None: + # Register exception handlers (must be after app creation) + import src.app.interfaces.api.error_handlers # noqa: F401, E402 + + app = init_app() + +register_error_handlers(app) From ca5b89d2337165f55cff96550f85d4964da089eb Mon Sep 17 00:00:00 2001 From: medniy2000 Date: Fri, 21 Nov 2025 14:53:33 +0200 Subject: [PATCH 3/8] improvements, refactoring --- src/app/application/container.py | 18 +-- src/app/application/services/auth_service.py | 88 +++++++++++---- .../application/services/common_service.py | 2 +- src/app/application/services/users_service.py | 2 +- src/app/domain/auth/__init__.py | 0 src/app/domain/auth/aggregates/__init__.py | 0 src/app/domain/auth/container.py | 22 ++++ src/app/domain/auth/services/__init__.py | 0 .../{users => auth}/services/auth_service.py | 4 +- src/app/domain/auth/services/jwt_service.py | 102 +++++++++++++++++ src/app/domain/auth/value_objects/__init__.py | 13 +++ src/app/domain/auth/value_objects/jwt.py | 65 +++++++++++ src/app/domain/common/container.py | 2 + src/app/domain/common/utils/__init__.py | 0 src/app/domain/common/utils/common.py | 106 ++++++++++++++++++ src/app/domain/users/container.py | 12 +- .../repositories/base/base_psql_repository.py | 2 +- src/app/infrastructure/utils/common.py | 8 -- src/app/interfaces/api/core/dependencies.py | 7 +- src/app/interfaces/api/core/jwt.py | 95 ---------------- .../api/v1/endpoints/auth/resources.py | 27 ++--- .../users/services/test_users_service.py | 2 +- tests/fixtures/constants.py | 2 +- .../repositories/test_users_repository.py | 2 +- 24 files changed, 414 insertions(+), 167 deletions(-) create mode 100644 src/app/domain/auth/__init__.py create mode 100644 src/app/domain/auth/aggregates/__init__.py create mode 100644 src/app/domain/auth/container.py create mode 100644 src/app/domain/auth/services/__init__.py rename src/app/domain/{users => auth}/services/auth_service.py (88%) create mode 100644 src/app/domain/auth/services/jwt_service.py create mode 100644 src/app/domain/auth/value_objects/__init__.py create mode 100644 src/app/domain/auth/value_objects/jwt.py create mode 100644 src/app/domain/common/container.py create mode 100644 src/app/domain/common/utils/__init__.py create mode 100644 src/app/domain/common/utils/common.py delete mode 100644 src/app/interfaces/api/core/jwt.py diff --git a/src/app/application/container.py b/src/app/application/container.py index 563fbff..c3d9bcd 100644 --- a/src/app/application/container.py +++ b/src/app/application/container.py @@ -6,22 +6,22 @@ class ApplicationServicesContainer: @property - def users_service(self) -> Type["src.app.application.services.users_service.UserService"]: - from src.app.application.services.users_service import UserService + def users_service(self) -> Type["src.app.application.services.users_service.AppUserService"]: + from src.app.application.services.users_service import AppUserService - return UserService + return AppUserService @property - def auth_service(self) -> Type["src.app.application.services.auth_service.AuthService"]: - from src.app.application.services.auth_service import AuthService + def auth_service(self) -> Type["src.app.application.services.auth_service.AppUserService"]: + from src.app.application.services.auth_service import AppUserService - return AuthService + return AppUserService @property - def common_service(self) -> Type["src.app.application.services.common_service.CommonApplicationService"]: - from src.app.application.services.common_service import CommonApplicationService + def common_service(self) -> Type["src.app.application.services.common_service.AppCommonService"]: + from src.app.application.services.common_service import AppCommonService - return CommonApplicationService + return AppCommonService container = ApplicationServicesContainer() diff --git a/src/app/application/services/auth_service.py b/src/app/application/services/auth_service.py index 27fd0e0..c5245b4 100644 --- a/src/app/application/services/auth_service.py +++ b/src/app/application/services/auth_service.py @@ -1,55 +1,97 @@ from copy import deepcopy from typing import Any -from fastapi import HTTPException, status from pydantic import validate_email from src.app.application.common.services.base import AbstractBaseApplicationService from src.app.application.container import container as app_services_container, ApplicationServicesContainer -from src.app.domain.users.container import container as domain_services_container, DomainServicesContainer +from src.app.domain.common.exceptions import AlreadyExistsError, ValidationError +from src.app.domain.common.utils.common import mask_string +from src.app.domain.auth.container import container as domain_auth_svc_container, DomainAuthServiceContainer +from src.app.domain.auth.value_objects import TokenPair, DecodedToken +from src.app.domain.users.container import container as domain_users_svc_container, DomainUsersServiceContainer -class AuthService(AbstractBaseApplicationService): +class AppAuthService(AbstractBaseApplicationService): + # Application Layer Services app_svc_container: ApplicationServicesContainer = app_services_container - dom_svc_container: DomainServicesContainer = domain_services_container - auth_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) + # Domain Layer Services + dom_users_svc_container: DomainUsersServiceContainer = domain_users_svc_container + dom_auth_svc_container: DomainAuthServiceContainer = domain_auth_svc_container + + async def send_verification_code(self, phone_number: str) -> None: + pass @classmethod async def create_auth_user(cls, data: dict) -> Any: data_ = deepcopy(data) email = data_.get("email") or "" - try: - email_validated = validate_email(email)[1] - is_email_exists = await cls.app_svc_container.users_service.is_exists( - filter_data={"email": email_validated} + + email_validated = validate_email(email)[1] + is_email_exists = await cls.app_svc_container.users_service.is_exists( + filter_data={"email": email_validated} + ) + if is_email_exists or not email: + raise AlreadyExistsError( + message="User already exists", + details=[{"key": "email", "value": email_validated}], ) - if is_email_exists or not email: - raise HTTPException(status_code=422, detail="User already exists with email") - data["email"] = email_validated - except Exception: - raise HTTPException(status_code=422, detail="Invalid value for email") + data["email"] = email_validated password = data_.pop("password", None) or "" - password_hashed = cls.dom_svc_container.auth_service.get_password_hashed(password) + password_hashed = cls.dom_auth_svc_container.auth_service.get_password_hashed(password) data_["password_hashed"] = password_hashed return await cls.app_svc_container.users_service.create(data_, is_return_require=True) @classmethod - async def get_auth_user(cls, email: str, password: str) -> Any: + async def get_auth_user_by_email_password(cls, email: str, password: str) -> Any: try: email_validated = validate_email(email)[1] except Exception: - raise HTTPException(status_code=422, detail=f"Invalid value {email}") + raise ValidationError( + message="Invalid value", + details=[{"key": "email", "value": mask_string(email, keep_start=1, keep_end=4)}], + ) user = await cls.app_svc_container.users_service.get_first(filter_data={"email": email_validated}) - is_password_verified = cls.dom_svc_container.auth_service.verify_password( + is_password_verified = cls.dom_auth_svc_container.auth_service.verify_password( password, getattr(user, "password_hashed") ) if not user or not is_password_verified: - raise HTTPException(status_code=422, detail="username or password is incorrect") + raise ValidationError( + message="One(or more) value(s) invalid", + details=[ + {"key": "email", "value": mask_string(email, keep_start=1, keep_end=4)}, + {"key": "password", "value": mask_string(password, keep_start=1, keep_end=2)}, + ], + ) return user + + @classmethod + async def get_auth_user_by_phone_number(cls, phone_number: str, verification_code: str) -> Any: + user = await cls.app_svc_container.users_service.get_first(filter_data={"phone": phone_number}) + return user + + @classmethod + def create_tokens_for_user(cls, uuid: str) -> TokenPair: + """Create access and refresh tokens for a user.""" + return cls.dom_auth_svc_container.jwt_service.create_token_pair(uuid) + + @classmethod + def verify_access_token(cls, token: str) -> DecodedToken: + """Verify an access token and return decoded data.""" + return cls.dom_auth_svc_container.jwt_service.verify_access_token(token) + + @classmethod + def verify_refresh_token(cls, token: str) -> DecodedToken: + """Verify a refresh token and return decoded data.""" + return cls.dom_auth_svc_container.jwt_service.verify_refresh_token(token) + + @classmethod + async def refresh_tokens(cls, refresh_token: str) -> tuple[Any, TokenPair]: + """Verify refresh token, get user, and create new token pair.""" + decoded = cls.verify_refresh_token(refresh_token) + user = await cls.app_svc_container.users_service.get_first(filter_data={"uuid": decoded.uuid}) + new_tokens = cls.create_tokens_for_user(str(getattr(user, "uuid", ""))) + return user, new_tokens diff --git a/src/app/application/services/common_service.py b/src/app/application/services/common_service.py index 4a76a16..6fd6f2d 100644 --- a/src/app/application/services/common_service.py +++ b/src/app/application/services/common_service.py @@ -4,7 +4,7 @@ from loguru import logger -class CommonApplicationService(AbstractBaseApplicationService): +class AppCommonService(AbstractBaseApplicationService): @classmethod async def is_healthy(cls) -> bool: diff --git a/src/app/application/services/users_service.py b/src/app/application/services/users_service.py index 537e2a0..7e8f207 100644 --- a/src/app/application/services/users_service.py +++ b/src/app/application/services/users_service.py @@ -2,5 +2,5 @@ from src.app.application.common.services.base import BaseApplicationService -class UserService(BaseApplicationService): +class AppUserService(BaseApplicationService): repository = repo_container.users_repository diff --git a/src/app/domain/auth/__init__.py b/src/app/domain/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/domain/auth/aggregates/__init__.py b/src/app/domain/auth/aggregates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/domain/auth/container.py b/src/app/domain/auth/container.py new file mode 100644 index 0000000..8232095 --- /dev/null +++ b/src/app/domain/auth/container.py @@ -0,0 +1,22 @@ +# flake8: noqa +from typing import Type +import src +from src.app.domain.common.container import DomainBaseServicesContainer + + +class DomainAuthServiceContainer(DomainBaseServicesContainer): + + @property + def auth_service(self) -> Type["src.app.domain.auth.services.auth_service.DomainAuthService"]: + from src.app.domain.auth.services.auth_service import DomainAuthService + + return DomainAuthService + + @property + def jwt_service(self) -> Type["src.app.domain.auth.services.jwt_service.DomainJWTService"]: + from src.app.domain.auth.services.jwt_service import DomainJWTService + + return DomainJWTService + + +container = DomainAuthServiceContainer() diff --git a/src/app/domain/auth/services/__init__.py b/src/app/domain/auth/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/domain/users/services/auth_service.py b/src/app/domain/auth/services/auth_service.py similarity index 88% rename from src/app/domain/users/services/auth_service.py rename to src/app/domain/auth/services/auth_service.py index 66b0962..92ce7d6 100644 --- a/src/app/domain/users/services/auth_service.py +++ b/src/app/domain/auth/services/auth_service.py @@ -4,7 +4,9 @@ from src.app.domain.common.services.base import AbstractBaseDomainService -class AuthDomainService(AbstractBaseDomainService): +class DomainAuthService(AbstractBaseDomainService): + """Domain service for password operations.""" + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @classmethod diff --git a/src/app/domain/auth/services/jwt_service.py b/src/app/domain/auth/services/jwt_service.py new file mode 100644 index 0000000..b0904b9 --- /dev/null +++ b/src/app/domain/auth/services/jwt_service.py @@ -0,0 +1,102 @@ +import datetime as dt +from datetime import timedelta +from typing import Optional + +from jose import jwt +from loguru import logger + +from src.app.domain.common.exceptions import AuthenticationError +from src.app.config.settings import settings +from src.app.domain.auth.value_objects import TokenType, TokenPair, DecodedToken +from src.app.domain.common.services.base import AbstractBaseDomainService +from src.app.domain.common.utils.common import generate_str + + +class DomainJWTService(AbstractBaseDomainService): + """Domain service for JWT token operations.""" + + SECRET = settings.SECRET_KEY + ACCESS_TOKEN_EXPIRES_MINUTES = settings.ACCESS_TOKEN_EXPIRES_MINUTES + REFRESH_TOKEN_EXPIRES_DAYS = settings.REFRESH_TOKEN_EXPIRES_DAYS + ALGORITHM = settings.ALGORITHM + + @classmethod + def _get_auth_exception(cls) -> AuthenticationError: + return AuthenticationError( + message="Token is invalid", + details=[], + extra={"headers": {"WWW-Authenticate": "Bearer"}}, + ) + + @classmethod + def _decode(cls, token: str) -> Optional[dict]: + """Decode a JWT token and return the payload.""" + try: + payload = jwt.decode(token=token, key=cls.SECRET, algorithms=[cls.ALGORITHM]) + return payload + except Exception as e: + logger.info(f"Token decode error: {e}") + raise cls._get_auth_exception() + + @classmethod + def create_access_token(cls, data: dict) -> str: + """Create a new access token with the given data.""" + user_data = data.copy() + expire = dt.datetime.now(dt.UTC) + timedelta(minutes=cls.ACCESS_TOKEN_EXPIRES_MINUTES) + payload = {"user": user_data, "type": TokenType.ACCESS.value, "exp": expire} + encoded_jwt = jwt.encode(payload, cls.SECRET, algorithm=cls.ALGORITHM) + return encoded_jwt + + @classmethod + def create_refresh_token(cls, data: dict) -> str: + """Create a new refresh token with the given data.""" + user_data = data.copy() + expire = dt.datetime.now(dt.UTC) + timedelta(days=cls.REFRESH_TOKEN_EXPIRES_DAYS) + payload = {"user": user_data, "type": TokenType.REFRESH.value, "exp": expire} + encoded_jwt = jwt.encode(payload, cls.SECRET, algorithm=cls.ALGORITHM) + return encoded_jwt + + @classmethod + def create_token_pair(cls, uuid: str) -> TokenPair: + """Create a pair of access and refresh tokens for the given user UUID.""" + access_sid: str = generate_str(size=6) + refresh_sid: str = f"{generate_str(size=8)}#{access_sid}" + + access_token_payload = { + "uuid": uuid, + "sid": access_sid, + } + refresh_token_payload = { + "uuid": uuid, + "sid": refresh_sid, + } + + access_token = cls.create_access_token(access_token_payload) + refresh_token = cls.create_refresh_token(refresh_token_payload) + + return TokenPair( + access_token=access_token, + refresh_token=refresh_token, + ) + + @classmethod + def verify_access_token(cls, token: str) -> DecodedToken: + """Verify an access token and return the decoded data.""" + payload = cls._decode(token) or {} + token_type = payload.get("type", "") or "" + + if payload and token_type == TokenType.ACCESS.value: + return DecodedToken.from_payload(payload, TokenType.ACCESS) + + raise cls._get_auth_exception() + + @classmethod + def verify_refresh_token(cls, token: str) -> DecodedToken: + """Verify a refresh token and return the decoded data.""" + payload = cls._decode(token) or {} + token_type = payload.get("type", "") or "" + + if payload and token_type == TokenType.REFRESH.value: + return DecodedToken.from_payload(payload, TokenType.REFRESH) + + raise cls._get_auth_exception() diff --git a/src/app/domain/auth/value_objects/__init__.py b/src/app/domain/auth/value_objects/__init__.py new file mode 100644 index 0000000..9639479 --- /dev/null +++ b/src/app/domain/auth/value_objects/__init__.py @@ -0,0 +1,13 @@ +from src.app.domain.auth.value_objects.jwt import ( + TokenType, + TokenPayload, + TokenPair, + DecodedToken, +) + +__all__ = [ + "TokenType", + "TokenPayload", + "TokenPair", + "DecodedToken", +] diff --git a/src/app/domain/auth/value_objects/jwt.py b/src/app/domain/auth/value_objects/jwt.py new file mode 100644 index 0000000..f610b1a --- /dev/null +++ b/src/app/domain/auth/value_objects/jwt.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Optional + + +class TokenType(str, Enum): + ACCESS = "access" + REFRESH = "refresh" + + +@dataclass(frozen=True) +class TokenPayload: + """Value object representing JWT token payload data.""" + + uuid: str + sid: str + token_type: TokenType + exp: datetime + + def to_dict(self) -> dict: + return { + "uuid": self.uuid, + "sid": self.sid, + } + + +@dataclass(frozen=True) +class TokenPair: + """Value object representing a pair of access and refresh tokens.""" + + access_token: str + refresh_token: str + + def to_dict(self) -> dict: + return { + "access": self.access_token, + "refresh": self.refresh_token, + } + + +@dataclass(frozen=True) +class DecodedToken: + """Value object representing decoded token data.""" + + uuid: str + sid: str + token_type: TokenType + exp: Optional[datetime] = None + + @classmethod + def from_payload(cls, payload: dict, token_type: TokenType) -> "DecodedToken": + user_data = payload.get("user", {}) + return cls( + uuid=user_data.get("uuid", ""), + sid=user_data.get("sid", ""), + token_type=token_type, + exp=payload.get("exp"), + ) + + def to_dict(self) -> dict: + return { + "uuid": self.uuid, + "sid": self.sid, + } diff --git a/src/app/domain/common/container.py b/src/app/domain/common/container.py new file mode 100644 index 0000000..5a0d202 --- /dev/null +++ b/src/app/domain/common/container.py @@ -0,0 +1,2 @@ +class DomainBaseServicesContainer: + pass diff --git a/src/app/domain/common/utils/__init__.py b/src/app/domain/common/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/domain/common/utils/common.py b/src/app/domain/common/utils/common.py new file mode 100644 index 0000000..0d9bd5b --- /dev/null +++ b/src/app/domain/common/utils/common.py @@ -0,0 +1,106 @@ +import random +import string + +default_chars = string.ascii_uppercase + string.ascii_lowercase + string.digits + + +def generate_str(size: int = 6, chars: str = default_chars) -> str: + """Generate a random string of specified size.""" + return "".join(random.choice(chars) for _ in range(size)) + + +def mask_string( + text: str, + *, + mask_char: str = "*", + keep_start: int = 0, + keep_end: int = 0, + keep_prefix: str = "", + keep_suffix: str = "", + preserve_chars: str = "", + min_length: int = 0, +) -> str: + """ + Generic string masking function. + + Args: + text: String to mask + mask_char: Character to use for masking (default: "*") + keep_start: Number of characters to keep at start + keep_end: Number of characters to keep at end + keep_prefix: Prefix to always keep (e.g., "sk_", "Bearer ") + keep_suffix: Suffix to always keep (e.g., domain for emails) + preserve_chars: Characters to not mask (e.g., "@", "-", " ") + min_length: Minimum length to apply masking (return original if shorter) + + Returns: + Masked string + + Examples: + # Email + mask_string("john@example.com", keep_start=1, keep_end=1, preserve_chars="@.") + # Output: j***@e*****.com + + # Phone + mask_string("+1-555-123-4567", keep_end=4, preserve_chars="+- ") + # Output: +*-***-***-4567 + + # Token + mask_string("sk_live_abc123xyz", keep_prefix="sk_", keep_start=2, keep_end=2) + # Output: sk_ab********yz + + # Credit card + mask_string("4532 1234 5678 9010", keep_end=4, preserve_chars=" ") + # Output: **** **** **** 9010 + + # Password (full mask) + mask_string("MyPassword123") + # Output: ************* + + # API Key + mask_string("eyJhbGciOiJIUzI1NiIs", keep_start=4, keep_end=4) + # Output: eyJh**********Is + """ + if not text or len(text) < min_length: + return text + + result = text + + # Handle prefix + if keep_prefix and result.startswith(keep_prefix): + prefix = keep_prefix + result = result[len(keep_prefix) :] + else: + prefix = "" + + # Handle suffix + if keep_suffix and result.endswith(keep_suffix): + suffix = keep_suffix + result = result[: -len(keep_suffix)] + else: + suffix = "" + + # Calculate positions + length = len(result) + + if length <= keep_start + keep_end: + # String too short, mask everything except preserved chars + if preserve_chars: + masked = "".join(c if c in preserve_chars else mask_char for c in result) + else: + masked = mask_char * length + else: + # Build masked string + start_part = result[:keep_start] + end_part = result[-keep_end:] if keep_end > 0 else "" + middle_part = result[keep_start : len(result) - keep_end if keep_end > 0 else len(result)] + + # Mask middle part + if preserve_chars: + masked_middle = "".join(c if c in preserve_chars else mask_char for c in middle_part) + else: + masked_middle = mask_char * len(middle_part) + + masked = start_part + masked_middle + end_part + + return prefix + masked + suffix diff --git a/src/app/domain/users/container.py b/src/app/domain/users/container.py index e81c05d..173ad8a 100644 --- a/src/app/domain/users/container.py +++ b/src/app/domain/users/container.py @@ -1,15 +1,11 @@ # flake8: noqa from typing import Type import src +from src.app.domain.common.container import DomainBaseServicesContainer -class DomainServicesContainer: +class DomainUsersServiceContainer(DomainBaseServicesContainer): + pass - @property - def auth_service(self) -> Type["src.app.domain.users.services.auth_service.AuthDomainService"]: - from src.app.domain.users.services.auth_service import AuthDomainService - return AuthDomainService - - -container = DomainServicesContainer() +container = DomainUsersServiceContainer() diff --git a/src/app/infrastructure/repositories/base/base_psql_repository.py b/src/app/infrastructure/repositories/base/base_psql_repository.py index b4e0842..5fdea8f 100644 --- a/src/app/infrastructure/repositories/base/base_psql_repository.py +++ b/src/app/infrastructure/repositories/base/base_psql_repository.py @@ -30,7 +30,7 @@ OuterGenericType, RepositoryError, ) -from src.app.infrastructure.utils.common import generate_str +from src.app.domain.common.utils.common import generate_str class PSQLLookupRegistry: diff --git a/src/app/infrastructure/utils/common.py b/src/app/infrastructure/utils/common.py index 0141b23..e69de29 100644 --- a/src/app/infrastructure/utils/common.py +++ b/src/app/infrastructure/utils/common.py @@ -1,8 +0,0 @@ -import string -import random - -default_chars = string.ascii_uppercase + string.ascii_lowercase + string.digits - - -def generate_str(size: int = 6, chars: str = default_chars) -> str: - return "".join(random.choice(chars) for _ in range(size)) diff --git a/src/app/interfaces/api/core/dependencies.py b/src/app/interfaces/api/core/dependencies.py index 7b2db59..61c8d9d 100644 --- a/src/app/interfaces/api/core/dependencies.py +++ b/src/app/interfaces/api/core/dependencies.py @@ -1,10 +1,9 @@ from fastapi import Depends from fastapi.security import HTTPBearer -from src.app.interfaces.api.core.jwt import JWTHelper +from src.app.application.container import container as app_svc_container auth_api_key_schema = HTTPBearer() -jwt_helper = JWTHelper() async def validate_api_key(auth_api_key: str = Depends(auth_api_key_schema)) -> str: @@ -14,5 +13,5 @@ async def validate_api_key(auth_api_key: str = Depends(auth_api_key_schema)) -> async def validate_auth_data(auth_api_key: str = Depends(auth_api_key_schema)) -> dict: auth_api_key_ = str(auth_api_key.credentials).replace("Bearer ", "") # type: ignore - data = await jwt_helper.verify_access_token(auth_api_key_) - return data + decoded = app_svc_container.auth_service.verify_access_token(auth_api_key_) + return decoded.to_dict() \ No newline at end of file diff --git a/src/app/interfaces/api/core/jwt.py b/src/app/interfaces/api/core/jwt.py deleted file mode 100644 index 945aebc..0000000 --- a/src/app/interfaces/api/core/jwt.py +++ /dev/null @@ -1,95 +0,0 @@ -import datetime as dt -from datetime import timedelta -from typing import Optional, Dict - -from fastapi import HTTPException, status -from jose import jwt -from loguru import logger - -from src.app.config.settings import settings - -from src.app.infrastructure.utils.common import generate_str - - -class JWTHelper: - SECRET = settings.SECRET_KEY - ACCESS_TOKEN_EXPIRES_MINUTES = settings.ACCESS_TOKEN_EXPIRES_MINUTES - REFRESH_TOKEN_EXPIRES_DAYS = settings.REFRESH_TOKEN_EXPIRES_DAYS - ALGORITHM = settings.ALGORITHM - exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - @classmethod - def _decode(cls, token: str) -> Optional[dict]: - try: - payload = jwt.decode(token=token, key=cls.SECRET, algorithms=[settings.ALGORITHM]) - return payload - except Exception as e: # noqa - logger.info(e) - return None - - @classmethod - async def create_access_token(cls, data: dict) -> str: - user_data = data.copy() - expire = dt.datetime.now(dt.UTC) + timedelta(minutes=cls.ACCESS_TOKEN_EXPIRES_MINUTES) - payload = {"user": user_data, "type": "access", "exp": expire} - encoded_jwt = jwt.encode(payload, cls.SECRET, algorithm=cls.ALGORITHM) - return encoded_jwt - - @classmethod - async def create_refresh_token(cls, data: dict) -> str: - user_data = data.copy() - expire = dt.datetime.now(dt.UTC) + timedelta(days=cls.REFRESH_TOKEN_EXPIRES_DAYS) - payload = {"user": user_data, "type": "refresh", "exp": expire} - encoded_jwt = jwt.encode(payload, cls.SECRET, algorithm=cls.ALGORITHM) - return encoded_jwt - - @classmethod - async def create_tokens_pair(cls, uuid: str) -> Dict[str, str]: - access_sid: str = generate_str(size=6) - refresh_sid: str = f"{generate_str(size=8)}#{access_sid}" - access_token_payload = { - "uuid": uuid, - "sid": access_sid, - } - refresh_token_payload = { - "uuid": uuid, - "sid": refresh_sid, - } - access_token = await cls.create_access_token(access_token_payload) - refresh_token = await cls.create_refresh_token(refresh_token_payload) - - return { - "access": access_token, - "refresh": refresh_token, - } - - @classmethod - async def verify_access_token(cls, token: str) -> dict: - payload = cls._decode(token) or {} - token_type = payload.get("type", "") or "" - if payload and token_type == "access": - user_data = payload.get("user", {}) - return user_data - raise cls.exception - - @classmethod - async def verify_refresh_token(cls, token: str) -> dict: - payload = cls._decode(token) or {} - token_type = payload.get("type", "") or "" - if payload and token_type == "refresh": - user_data = payload.get("user", {}) - return user_data - - raise cls.exception - - @classmethod - async def access_auth_data(cls, api_key: str) -> dict: - return await cls.verify_access_token(api_key) # type: ignore - - @classmethod - async def refresh_auth_data(cls, api_key: str) -> dict: - return await cls.verify_refresh_token(api_key) # type: ignore diff --git a/src/app/interfaces/api/v1/endpoints/auth/resources.py b/src/app/interfaces/api/v1/endpoints/auth/resources.py index 6c8f153..6922372 100644 --- a/src/app/interfaces/api/v1/endpoints/auth/resources.py +++ b/src/app/interfaces/api/v1/endpoints/auth/resources.py @@ -3,12 +3,12 @@ from fastapi import APIRouter, Body, Depends +from src.app.application.container import container as app_svc_container from src.app.interfaces.api.core.dependencies import validate_api_key -from src.app.interfaces.api.core.jwt import JWTHelper from src.app.interfaces.api.v1.endpoints.auth.schemas.req_schemas import SignUpReq from src.app.interfaces.api.v1.endpoints.auth.schemas.req_schemas import TokenReq -from src.app.interfaces.api.v1.endpoints.auth.schemas.resp_schemas import SignupResp, TokenResp -from src.app.application.container import container as services_container +from src.app.interfaces.api.v1.endpoints.auth.schemas.resp_schemas import SignupResp +from src.app.interfaces.api.v1.endpoints.auth.schemas.resp_schemas import TokenResp router = APIRouter(prefix="/auth") @@ -16,7 +16,7 @@ @router.post(path="/sign-up/", response_model=SignupResp, name="sign-up") async def sign_up(data: Annotated[SignUpReq, Body()]) -> dict: - user = await services_container.auth_service.create_auth_user(data=data.model_dump()) + user = await app_svc_container.auth_service.create_auth_user(data=data.model_dump()) return asdict(user) @@ -27,13 +27,15 @@ async def tokens( ) -> dict: """Get new access, refresh tokens [Based on email, password]""" - user = await services_container.auth_service.get_auth_user(email=data.email, password=data.password) + user = await app_svc_container.auth_service.get_auth_user_by_phone_number( + phone_number=data.phone_number, verification_code=data.verification_code + ) - new_tokens = await JWTHelper.create_tokens_pair(uuid=str(user.uuid)) # noqa + token_pair = app_svc_container.auth_service.create_tokens_for_user(uuid=str(user.uuid)) tokens_data = { "user_data": {"uuid": str(user.uuid)}, - "access": new_tokens["access"], - "refresh": new_tokens["refresh"], + "access": token_pair.access_token, + "refresh": token_pair.refresh_token, } return tokens_data @@ -43,13 +45,12 @@ async def tokens( async def tokens_refreshed(auth_api_key: str = Depends(validate_api_key)) -> dict: """Get new access, refresh tokens [Granted by refresh token in header]""" - refresh_data = await JWTHelper.refresh_auth_data(auth_api_key) - user = await services_container.users_service.get_first(filter_data={"uuid": refresh_data["uuid"]}) - new_tokens = await JWTHelper.create_tokens_pair(uuid=str(getattr(user, "uuid", ""))) + user, token_pair = await app_svc_container.auth_service.refresh_tokens(auth_api_key) tokens_data = { "user_data": {"uuid": str(getattr(user, "uuid", ""))}, - "access": new_tokens["access"], - "refresh": new_tokens["refresh"], + "access": token_pair.access_token, + "refresh": token_pair.refresh_token, } return tokens_data + diff --git a/tests/application/users/services/test_users_service.py b/tests/application/users/services/test_users_service.py index 18daa13..7f2baf9 100644 --- a/tests/application/users/services/test_users_service.py +++ b/tests/application/users/services/test_users_service.py @@ -7,7 +7,7 @@ import pytest from src.app.domain.users.aggregates.common import UserAggregate -from src.app.infrastructure.utils.common import generate_str +from src.app.domain.common.utils.common import generate_str from src.app.application.container import container as service_container from tests.fixtures.constants import USERS diff --git a/tests/fixtures/constants.py b/tests/fixtures/constants.py index d57756d..3e87f1c 100644 --- a/tests/fixtures/constants.py +++ b/tests/fixtures/constants.py @@ -5,7 +5,7 @@ from dateutil.relativedelta import relativedelta -from src.app.infrastructure.utils.common import generate_str +from src.app.domain.common.utils.common import generate_str USER_CREATED_AT = dt.datetime.now(dt.UTC).replace(tzinfo=None) - relativedelta(months=6) USER_UPDATED_AT = USER_CREATED_AT diff --git a/tests/infrastructure/repositories/test_users_repository.py b/tests/infrastructure/repositories/test_users_repository.py index fe29453..004effc 100644 --- a/tests/infrastructure/repositories/test_users_repository.py +++ b/tests/infrastructure/repositories/test_users_repository.py @@ -7,7 +7,7 @@ import pytest from src.app.infrastructure.repositories.base.abstract import RepositoryError -from src.app.infrastructure.utils.common import generate_str +from src.app.domain.common.utils.common import generate_str from src.app.infrastructure.repositories.container import container as repo_container from tests.domain.users.aggregates.common import UserTestAggregate from tests.fixtures.constants import USERS From 0cbb9935712773e05ba13fcfa7f1c5b1d1f95575 Mon Sep 17 00:00:00 2001 From: medniy2000 Date: Tue, 25 Nov 2025 16:42:02 +0200 Subject: [PATCH 4/8] improvements, refactoring.. --- src/app/application/common/dto/__init__.py | 0 src/app/application/common/dto/base.py | 6 + src/app/application/common/services/base.py | 159 ++++++++++++++---- src/app/application/container.py | 6 +- src/app/application/dto/__init__.py | 0 src/app/application/dto/user.py | 17 ++ src/app/application/services/auth_service.py | 51 ++---- src/app/application/services/users_service.py | 60 ++++++- src/app/domain/auth/value_objects/__init__.py | 2 +- .../auth/value_objects/{jwt.py => jwt_vob.py} | 0 src/app/domain/common/aggregates/base.py | 30 +++- src/app/domain/common/events/__init__.py | 0 src/app/domain/common/events/base.py | 22 +++ .../domain/common/repositories/__init__.py | 0 .../aggregates/{common.py => user_agg.py} | 4 +- src/app/domain/users/container.py | 6 +- .../domain/users/services/users_service.py | 7 + .../domain/users/value_objects/__init__.py | 0 .../domain/users/value_objects/users_vob.py | 101 +++++++++++ .../repositories/base/abstract.py | 34 ++-- .../repositories/base/base_psql_repository.py | 51 +++--- src/app/interfaces/api/error_handlers.py | 8 +- .../api/v1/endpoints/auth/resources.py | 10 +- .../v1/endpoints/auth/schemas/req_schemas.py | 24 --- 24 files changed, 443 insertions(+), 155 deletions(-) create mode 100644 src/app/application/common/dto/__init__.py create mode 100644 src/app/application/common/dto/base.py create mode 100644 src/app/application/dto/__init__.py create mode 100644 src/app/application/dto/user.py rename src/app/domain/auth/value_objects/{jwt.py => jwt_vob.py} (100%) create mode 100644 src/app/domain/common/events/__init__.py create mode 100644 src/app/domain/common/events/base.py create mode 100644 src/app/domain/common/repositories/__init__.py rename src/app/domain/users/aggregates/{common.py => user_agg.py} (84%) create mode 100644 src/app/domain/users/services/users_service.py create mode 100644 src/app/domain/users/value_objects/__init__.py create mode 100644 src/app/domain/users/value_objects/users_vob.py diff --git a/src/app/application/common/dto/__init__.py b/src/app/application/common/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/application/common/dto/base.py b/src/app/application/common/dto/base.py new file mode 100644 index 0000000..8698754 --- /dev/null +++ b/src/app/application/common/dto/base.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass +class AppBaseDTO: + pass \ No newline at end of file diff --git a/src/app/application/common/services/base.py b/src/app/application/common/services/base.py index 20e0466..8f98ff1 100644 --- a/src/app/application/common/services/base.py +++ b/src/app/application/common/services/base.py @@ -1,14 +1,21 @@ from abc import ABC -from typing import Any, Dict, Generic, List, Optional, Tuple, Type +from dataclasses import dataclass +from typing import Any, Dict, Generic, List, Optional, Tuple, Type, TypeVar -from src.app.infrastructure.repositories.base.abstract import AbstractBaseRepository, OuterGenericType +from src.app.infrastructure.repositories.base.abstract import AbstractBaseRepository +@dataclass +class BaseSvcOutEntity(ABC): + pass + +OutSvcGenericType = TypeVar("OutSvcGenericType", bound=BaseSvcOutEntity) + class AbstractBaseApplicationService(ABC): pass -class AbstractApplicationService(AbstractBaseApplicationService, Generic[OuterGenericType]): +class AbstractApplicationService(AbstractBaseApplicationService, Generic[OutSvcGenericType]): @classmethod async def count(cls, filter_data: dict) -> int: raise NotImplementedError @@ -18,41 +25,69 @@ async def is_exists(cls, filter_data: dict) -> bool: raise NotImplementedError @classmethod - async def get_first(cls, filter_data: dict) -> OuterGenericType | None: + async def get_first( + cls, + filter_data: dict, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> OutSvcGenericType | None: raise NotImplementedError @classmethod async def get_list( - cls, filter_data: dict, offset: int = 0, limit: Optional[int] = None, order_data: Tuple[str] = ("id",) - ) -> List[OuterGenericType]: + cls, + filter_data: dict, + offset: int = 0, + limit: Optional[int] = None, + order_data: Tuple[str] = ("id",), + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> List[OutSvcGenericType]: raise NotImplementedError @classmethod - async def create(cls, data: dict, is_return_require: bool = False) -> OuterGenericType | None: + async def create( + cls, + data: dict, + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> OutSvcGenericType | None: raise NotImplementedError @classmethod async def create_bulk( - cls, items: List[dict], is_return_require: bool = False - ) -> List[OuterGenericType] | None: + cls, + items: List[dict], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> List[OutSvcGenericType] | None: raise NotImplementedError @classmethod async def update( - cls, filter_data: dict, data: Dict[str, Any], is_return_require: bool = False - ) -> OuterGenericType | None: + cls, + filter_data: dict, + data: Dict[str, Any], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> OutSvcGenericType | None: raise NotImplementedError @classmethod async def update_bulk( - cls, items: List[dict], is_return_require: bool = False - ) -> List[OuterGenericType] | None: + cls, + items: List[dict], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> List[OutSvcGenericType] | None: raise NotImplementedError @classmethod async def update_or_create( - cls, filter_data: dict, data: Dict[str, Any], is_return_require: bool = False - ) -> OuterGenericType | None: + cls, + filter_data: dict, + data: Dict[str, Any], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> OutSvcGenericType | None: raise NotImplementedError @classmethod @@ -63,8 +98,8 @@ async def remove( raise NotImplementedError -class BaseApplicationService(AbstractApplicationService[OuterGenericType], Generic[OuterGenericType]): - repository: Type[AbstractBaseRepository[OuterGenericType]] +class BaseApplicationService(AbstractApplicationService[OutSvcGenericType], Generic[OutSvcGenericType]): + repository: Type[AbstractBaseRepository[OutSvcGenericType]] @classmethod async def count(cls, filter_data: dict) -> int: @@ -75,48 +110,100 @@ async def is_exists(cls, filter_data: dict) -> bool: return await cls.repository.is_exists(filter_data=filter_data) @classmethod - async def get_first(cls, filter_data: dict) -> OuterGenericType | None: - item = await cls.repository.get_first(filter_data=filter_data) + async def get_first( + cls, + filter_data: dict, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> OutSvcGenericType | None: + item = await cls.repository.get_first(filter_data=filter_data, out_dataclass=out_dataclass) return item @classmethod async def get_list( - cls, filter_data: dict, offset: int = 0, limit: Optional[int] = None, order_data: Tuple[str] = ("id",) - ) -> List[OuterGenericType]: + cls, + filter_data: dict, + offset: int = 0, + limit: Optional[int] = None, + order_data: Tuple[str] = ("id",), + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> List[OutSvcGenericType]: filter_data_ = filter_data.copy() filter_data_["offset"] = offset if limit is not None: filter_data_["limit"] = limit - return await cls.repository.get_list(filter_data=filter_data_, order_data=order_data) + return await cls.repository.get_list( + filter_data=filter_data_, + order_data=order_data, + out_dataclass=out_dataclass + ) @classmethod - async def create(cls, data: dict, is_return_require: bool = False) -> OuterGenericType | None: - return await cls.repository.create(data=data, is_return_require=is_return_require) + async def create( + cls, data: dict, + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> OutSvcGenericType | None: + return await cls.repository.create( + data=data, + is_return_require=is_return_require, + out_dataclass=out_dataclass + ) @classmethod async def create_bulk( - cls, items: List[dict], is_return_require: bool = False - ) -> List[OuterGenericType] | None: - return await cls.repository.create_bulk(items=items, is_return_require=is_return_require) + cls, + items: List[dict], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> List[OutSvcGenericType] | None: + return await cls.repository.create_bulk( + items=items, + is_return_require=is_return_require, + out_dataclass=out_dataclass + ) @classmethod async def update( - cls, filter_data: dict, data: Dict[str, Any], is_return_require: bool = False - ) -> OuterGenericType | None: - return await cls.repository.update(filter_data=filter_data, data=data, is_return_require=is_return_require) + cls, + filter_data: dict, + data: Dict[str, Any], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> OutSvcGenericType | None: + return await cls.repository.update( + filter_data=filter_data, + data=data, + is_return_require=is_return_require, + out_dataclass=out_dataclass + + ) @classmethod async def update_bulk( - cls, items: List[dict], is_return_require: bool = False - ) -> List[OuterGenericType] | None: - return await cls.repository.update_bulk(items=items, is_return_require=is_return_require) + cls, + items: List[dict], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> List[OutSvcGenericType] | None: + return await cls.repository.update_bulk( + items=items, + is_return_require=is_return_require, + out_dataclass=out_dataclass + ) @classmethod async def update_or_create( - cls, filter_data: dict, data: Dict[str, Any], is_return_require: bool = False - ) -> OuterGenericType | None: + cls, + filter_data: dict, + data: Dict[str, Any], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, + ) -> OutSvcGenericType | None: return await cls.repository.update_or_create( - filter_data=filter_data, data=data, is_return_require=is_return_require + filter_data=filter_data, + data=data, + is_return_require=is_return_require, + out_dataclass=out_dataclass ) @classmethod diff --git a/src/app/application/container.py b/src/app/application/container.py index c3d9bcd..5b85e1c 100644 --- a/src/app/application/container.py +++ b/src/app/application/container.py @@ -12,10 +12,10 @@ def users_service(self) -> Type["src.app.application.services.users_service.AppU return AppUserService @property - def auth_service(self) -> Type["src.app.application.services.auth_service.AppUserService"]: - from src.app.application.services.auth_service import AppUserService + def auth_service(self) -> Type["src.app.application.services.auth_service.AppAuthService"]: + from src.app.application.services.auth_service import AppAuthService - return AppUserService + return AppAuthService @property def common_service(self) -> Type["src.app.application.services.common_service.AppCommonService"]: diff --git a/src/app/application/dto/__init__.py b/src/app/application/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/application/dto/user.py b/src/app/application/dto/user.py new file mode 100644 index 0000000..bae82a3 --- /dev/null +++ b/src/app/application/dto/user.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Optional + +from uuid import UUID + +from src.app.application.common.dto.base import AppBaseDTO + + +@dataclass +class UserShortDTO(AppBaseDTO): + id: int + uuid: UUID + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + phone: Optional[str] + is_active: bool \ No newline at end of file diff --git a/src/app/application/services/auth_service.py b/src/app/application/services/auth_service.py index c5245b4..eab4cf4 100644 --- a/src/app/application/services/auth_service.py +++ b/src/app/application/services/auth_service.py @@ -1,14 +1,14 @@ -from copy import deepcopy from typing import Any from pydantic import validate_email from src.app.application.common.services.base import AbstractBaseApplicationService from src.app.application.container import container as app_services_container, ApplicationServicesContainer -from src.app.domain.common.exceptions import AlreadyExistsError, ValidationError -from src.app.domain.common.utils.common import mask_string +from src.app.application.dto.user import UserShortDTO from src.app.domain.auth.container import container as domain_auth_svc_container, DomainAuthServiceContainer from src.app.domain.auth.value_objects import TokenPair, DecodedToken +from src.app.domain.common.exceptions import ValidationError +from src.app.domain.common.utils.common import mask_string from src.app.domain.users.container import container as domain_users_svc_container, DomainUsersServiceContainer @@ -20,29 +20,6 @@ class AppAuthService(AbstractBaseApplicationService): dom_users_svc_container: DomainUsersServiceContainer = domain_users_svc_container dom_auth_svc_container: DomainAuthServiceContainer = domain_auth_svc_container - async def send_verification_code(self, phone_number: str) -> None: - pass - - @classmethod - async def create_auth_user(cls, data: dict) -> Any: - data_ = deepcopy(data) - email = data_.get("email") or "" - - email_validated = validate_email(email)[1] - is_email_exists = await cls.app_svc_container.users_service.is_exists( - filter_data={"email": email_validated} - ) - if is_email_exists or not email: - raise AlreadyExistsError( - message="User already exists", - details=[{"key": "email", "value": email_validated}], - ) - data["email"] = email_validated - - password = data_.pop("password", None) or "" - password_hashed = cls.dom_auth_svc_container.auth_service.get_password_hashed(password) - data_["password_hashed"] = password_hashed - return await cls.app_svc_container.users_service.create(data_, is_return_require=True) @classmethod async def get_auth_user_by_email_password(cls, email: str, password: str) -> Any: @@ -68,16 +45,6 @@ async def get_auth_user_by_email_password(cls, email: str, password: str) -> Any ) return user - @classmethod - async def get_auth_user_by_phone_number(cls, phone_number: str, verification_code: str) -> Any: - user = await cls.app_svc_container.users_service.get_first(filter_data={"phone": phone_number}) - return user - - @classmethod - def create_tokens_for_user(cls, uuid: str) -> TokenPair: - """Create access and refresh tokens for a user.""" - return cls.dom_auth_svc_container.jwt_service.create_token_pair(uuid) - @classmethod def verify_access_token(cls, token: str) -> DecodedToken: """Verify an access token and return decoded data.""" @@ -89,9 +56,17 @@ def verify_refresh_token(cls, token: str) -> DecodedToken: return cls.dom_auth_svc_container.jwt_service.verify_refresh_token(token) @classmethod - async def refresh_tokens(cls, refresh_token: str) -> tuple[Any, TokenPair]: + def create_tokens_for_user(cls, uuid: str) -> TokenPair: + """Create access and refresh tokens for a user.""" + return cls.dom_auth_svc_container.jwt_service.create_token_pair(uuid) + + @classmethod + async def refresh_tokens(cls, refresh_token: str) -> tuple[UserShortDTO, TokenPair]: """Verify refresh token, get user, and create new token pair.""" decoded = cls.verify_refresh_token(refresh_token) - user = await cls.app_svc_container.users_service.get_first(filter_data={"uuid": decoded.uuid}) + user = await cls.app_svc_container.users_service.get_first( + filter_data={"uuid": decoded.uuid}, + out_dataclass=UserShortDTO + ) new_tokens = cls.create_tokens_for_user(str(getattr(user, "uuid", ""))) return user, new_tokens diff --git a/src/app/application/services/users_service.py b/src/app/application/services/users_service.py index 7e8f207..b7931f6 100644 --- a/src/app/application/services/users_service.py +++ b/src/app/application/services/users_service.py @@ -1,6 +1,64 @@ -from src.app.infrastructure.repositories.container import container as repo_container +from pydantic import validate_email + from src.app.application.common.services.base import BaseApplicationService +from src.app.application.container import container as app_services_container, ApplicationServicesContainer +from src.app.application.dto.user import UserShortDTO +from src.app.domain.auth.container import container as domain_auth_svc_container, DomainAuthServiceContainer +from src.app.domain.common.exceptions import AlreadyExistsError +from src.app.domain.common.utils.common import mask_string +from src.app.domain.users.container import container as domain_users_svc_container, DomainUsersServiceContainer +from src.app.domain.users.value_objects.users_vob import EmailPasswordPair, PhoneNumberCodePair +from src.app.infrastructure.repositories.container import container as repo_container class AppUserService(BaseApplicationService): + + # Application Layer Services + app_svc_container: ApplicationServicesContainer = app_services_container + + # Domain Layer Services + dom_users_svc_container: DomainUsersServiceContainer = domain_users_svc_container + dom_auth_svc_container: DomainAuthServiceContainer = domain_auth_svc_container + + # Repositories repository = repo_container.users_repository + + @classmethod + async def create_user_by_email(cls, email: str, password: str) -> UserShortDTO: + _, validated_email = validate_email(email) + validated_data = EmailPasswordPair(email=validated_email, password=password) + password_hashed = domain_auth_svc_container.auth_service.get_password_hashed(password=password) + is_email_exists = await cls.app_svc_container.users_service.is_exists( + filter_data={"email": validated_data.email} + ) + if is_email_exists or not email: + raise AlreadyExistsError( + message="Already exists", + details=[ + {"key": "email", "value": mask_string(validated_data.email, keep_start=1, keep_end=4)}, + ], + ) + + data = { + "email": validated_data.email, + "password_hashed": password_hashed, + } + user_dto = await cls.create(data, is_return_require=True, out_dataclass=UserShortDTO) + return user_dto + + + @classmethod + async def create_user_by_phone(cls, phone: str, verification_code: str) -> UserShortDTO: + validated_data = PhoneNumberCodePair(phone=phone, verification_code=verification_code) + is_phone_exists = await cls.app_svc_container.users_service.is_exists( + filter_data={"phone": validated_data.phone} + ) + if is_phone_exists: + raise AlreadyExistsError( + message="Already exists", + details=[ + {"key": "phone", "value": mask_string(validated_data.phone, keep_start=2, keep_end=2)}, + ], + ) + user_dto = await cls.create(validated_data.to_dict(), is_return_require=True, out_dataclass=UserShortDTO) + return user_dto diff --git a/src/app/domain/auth/value_objects/__init__.py b/src/app/domain/auth/value_objects/__init__.py index 9639479..184b45e 100644 --- a/src/app/domain/auth/value_objects/__init__.py +++ b/src/app/domain/auth/value_objects/__init__.py @@ -1,4 +1,4 @@ -from src.app.domain.auth.value_objects.jwt import ( +from src.app.domain.auth.value_objects.jwt_vob import ( TokenType, TokenPayload, TokenPair, diff --git a/src/app/domain/auth/value_objects/jwt.py b/src/app/domain/auth/value_objects/jwt_vob.py similarity index 100% rename from src/app/domain/auth/value_objects/jwt.py rename to src/app/domain/auth/value_objects/jwt_vob.py diff --git a/src/app/domain/common/aggregates/base.py b/src/app/domain/common/aggregates/base.py index 2eac82c..833abff 100644 --- a/src/app/domain/common/aggregates/base.py +++ b/src/app/domain/common/aggregates/base.py @@ -1,5 +1,29 @@ -from typing import TypedDict +from typing import List +from src.app.domain.common.events.base import DomainEvent -class BaseAggregate(TypedDict): - pass + +class BaseAggregate: + + def __init__(self) -> None: + self._events: List[DomainEvent] = [] + + def add_event(self, event: DomainEvent) -> None: + """Add a domain event to the aggregate.""" + self._events.append(event) + + def events_clear(self) -> None: + """Clear all domain events.""" + self._events.clear() + + def get_events(self) -> List[DomainEvent]: + """Get all domain events.""" + return self._events.copy() + + def events_load(self, raw_events: List[dict]) -> List[DomainEvent]: + """Get all domain events.""" + pass + + def has_events(self) -> bool: + """Check if aggregate has any events.""" + return len(self._events) > 0 diff --git a/src/app/domain/common/events/__init__.py b/src/app/domain/common/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/domain/common/events/base.py b/src/app/domain/common/events/base.py new file mode 100644 index 0000000..828c971 --- /dev/null +++ b/src/app/domain/common/events/base.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict + + +@dataclass(frozen=True) +class DomainEvent: + """Base class for all domain events.""" + + id: int + created_at: datetime + event: str + payload: Dict[str, Any] + + def to_dict(self) -> dict: + """Convert event to dictionary.""" + return { + "id": self.id, + "created_at": self.created_at, + "event": self.event, + "payload": self.payload, + } \ No newline at end of file diff --git a/src/app/domain/common/repositories/__init__.py b/src/app/domain/common/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/domain/users/aggregates/common.py b/src/app/domain/users/aggregates/user_agg.py similarity index 84% rename from src/app/domain/users/aggregates/common.py rename to src/app/domain/users/aggregates/user_agg.py index 4f1dd9d..9d58da6 100644 --- a/src/app/domain/users/aggregates/common.py +++ b/src/app/domain/users/aggregates/user_agg.py @@ -2,9 +2,11 @@ from datetime import datetime from typing import Any, Dict +from src.app.domain.common.aggregates.base import BaseAggregate + @dataclass -class UserAggregate: +class UserAggregate(BaseAggregate): id: int uuid: str meta: Dict[str, Any] | Any diff --git a/src/app/domain/users/container.py b/src/app/domain/users/container.py index 173ad8a..1e7a7b6 100644 --- a/src/app/domain/users/container.py +++ b/src/app/domain/users/container.py @@ -5,7 +5,11 @@ class DomainUsersServiceContainer(DomainBaseServicesContainer): - pass + + @property + def users_service(self) -> Type["src.app.domain.users.services.users_service.DomainUsersService"]: + from src.app.domain.users.services.users_service import DomainUsersService + return DomainUsersService container = DomainUsersServiceContainer() diff --git a/src/app/domain/users/services/users_service.py b/src/app/domain/users/services/users_service.py new file mode 100644 index 0000000..8b928f1 --- /dev/null +++ b/src/app/domain/users/services/users_service.py @@ -0,0 +1,7 @@ +from src.app.domain.common.services.base import AbstractBaseDomainService + + +class DomainUsersService(AbstractBaseDomainService): + """Domain Users service""" + + pass \ No newline at end of file diff --git a/src/app/domain/users/value_objects/__init__.py b/src/app/domain/users/value_objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/domain/users/value_objects/users_vob.py b/src/app/domain/users/value_objects/users_vob.py new file mode 100644 index 0000000..5a52244 --- /dev/null +++ b/src/app/domain/users/value_objects/users_vob.py @@ -0,0 +1,101 @@ +import re +from dataclasses import dataclass + +from src.app.domain.common.exceptions import ValidationError +from src.app.domain.common.utils.common import mask_string + + +@dataclass(frozen=True) +class EmailPasswordPair: + """Value object representing a pair of email and password""" + + email: str + password: str + + def __post_init__(self): + self.__validate_email(value=self.email) + self.__validate_password(value=self.password) + + @staticmethod + def __validate_email(value: str) -> None: + # TODO: implement validation + pass + + @staticmethod + def __validate_password(value: str) -> None: + details = [ + {"key": "password", "value": mask_string(value, keep_start=1, keep_end=1)}, + ] + if len(value) < 8: + raise ValidationError( + message="Must be at least 8 characters long", + details=details + ) + + # Check for at least one uppercase letter + if not re.search(r"[A-Z]", value): + raise ValidationError( + message="Must contain at least one uppercase letter", + details=details + ) + + # Check for at least one lowercase letter + if not re.search(r"[a-z]", value): + raise ValidationError( + message="Must contain at least one lowercase letter", + details=details + ) + + # Check for at least one digit + if not re.search(r"[0-9]", value): + raise ValidationError( + message="Must contain at least one digit", + details=details + ) + + # Check for at least one special character + if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", value): + raise ValidationError( + message="Must contain at least one special character", + details=details + ) + + + def to_dict(self) -> dict: + return { + "email": self.email, + "password_hashed": self.password, + } + +@dataclass(frozen=True) +class PhoneNumberCodePair: + """Value object representing a pair of phone number and code""" + + phone: str + verification_code: str + + def __post_init__(self): + self.__validate_phone(value=self.phone) + self.__validate_verification_code(value=self.verification_code) + + @staticmethod + def __validate_phone(value: str) -> None: + pattern = r"^\+?\d{1,3}[-.\s]?\(?\d{1,4}\)?[-.\s]?\d{1,4}[-.\s]?\d{1,9}$" + match = re.match(pattern, value) + if not match or 8 > len(value) or len(value) > 16: + raise ValidationError( + message="Invalid value", + details=[{"key": "phone", "value": mask_string(value, keep_start=2, keep_end=2)}] + ) + + pass + + @staticmethod + def __validate_verification_code(value: str) -> None: + pass + + def to_dict(self) -> dict: + return { + "phone": self.phone, + "verification_code": self.verification_code, + } diff --git a/src/app/infrastructure/repositories/base/abstract.py b/src/app/infrastructure/repositories/base/abstract.py index ee3e04e..26c40c1 100644 --- a/src/app/infrastructure/repositories/base/abstract.py +++ b/src/app/infrastructure/repositories/base/abstract.py @@ -14,15 +14,15 @@ class AbstractRepository(ABC): @dataclass -class BaseOutputEntity(ABC): +class BaseOutEntity(ABC): pass BaseModel = TypeVar("BaseModel", bound=Base) -OuterGenericType = TypeVar("OuterGenericType", bound=BaseOutputEntity) +OutRepoGenericType = TypeVar("OutRepoGenericType", bound=BaseOutEntity) -class AbstractBaseRepository(AbstractRepository, Generic[OuterGenericType]): +class AbstractBaseRepository(AbstractRepository, Generic[OutRepoGenericType]): MODEL: Optional[Type[Base]] = None @classmethod @@ -35,26 +35,26 @@ async def is_exists(cls, filter_data: dict) -> bool: @classmethod async def get_first( - cls, filter_data: dict, out_dataclass: Optional[OuterGenericType] = None - ) -> OuterGenericType | None: + cls, filter_data: dict, out_dataclass: Optional[OutRepoGenericType] = None + ) -> OutRepoGenericType | None: raise NotImplementedError @classmethod async def get_list( - cls, filter_data: dict, order_data: Tuple[str] = ("id",), out_dataclass: Optional[OuterGenericType] = None - ) -> List[OuterGenericType]: + cls, filter_data: dict, order_data: Tuple[str] = ("id",), out_dataclass: Optional[OutRepoGenericType] = None + ) -> List[OutRepoGenericType]: raise NotImplementedError @classmethod async def create( - cls, data: dict, is_return_require: bool = False, out_dataclass: Optional[OuterGenericType] = None - ) -> OuterGenericType | None: + cls, data: dict, is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + ) -> OutRepoGenericType | None: raise NotImplementedError @classmethod async def create_bulk( - cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OuterGenericType] = None - ) -> List[OuterGenericType] | None: + cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + ) -> List[OutRepoGenericType] | None: raise NotImplementedError @classmethod @@ -63,14 +63,14 @@ async def update( filter_data: dict, data: Dict[str, Any], is_return_require: bool = False, - out_dataclass: Optional[OuterGenericType] = None, - ) -> OuterGenericType | None: + out_dataclass: Optional[OutRepoGenericType] = None, + ) -> OutRepoGenericType | None: raise NotImplementedError @classmethod async def update_bulk( - cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OuterGenericType] = None - ) -> List[OuterGenericType] | None: + cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + ) -> List[OutRepoGenericType] | None: raise NotImplementedError @classmethod @@ -79,8 +79,8 @@ async def update_or_create( filter_data: dict, data: Dict[str, Any], is_return_require: bool = False, - out_dataclass: Optional[OuterGenericType] = None, - ) -> OuterGenericType | None: + out_dataclass: Optional[OutRepoGenericType] = None, + ) -> OutRepoGenericType | None: raise NotImplementedError @classmethod diff --git a/src/app/infrastructure/repositories/base/base_psql_repository.py b/src/app/infrastructure/repositories/base/base_psql_repository.py index 5fdea8f..41cab01 100644 --- a/src/app/infrastructure/repositories/base/base_psql_repository.py +++ b/src/app/infrastructure/repositories/base/base_psql_repository.py @@ -27,7 +27,7 @@ from src.app.infrastructure.extensions.psql_ext.psql_ext import Base, get_session from src.app.infrastructure.repositories.base.abstract import ( AbstractBaseRepository, - OuterGenericType, + OutRepoGenericType, RepositoryError, ) from src.app.domain.common.utils.common import generate_str @@ -595,7 +595,7 @@ def _parse_order_data(cls, order_data: Tuple[str, ...], model_class: Type[Base]) return parsed_order -class BasePSQLRepository(AbstractBaseRepository[OuterGenericType], Generic[OuterGenericType]): +class BasePSQLRepository(AbstractBaseRepository[OutRepoGenericType], Generic[OutRepoGenericType]): """ Base PostgreSQL repository with CRUD operations and bulk operations support. @@ -652,7 +652,7 @@ def _create_dynamic_dataclass(cls) -> Tuple[Callable, List[str]]: @classmethod def out_dataclass_with_columns( - cls, out_dataclass: Optional[OuterGenericType] = None + cls, out_dataclass: Optional[OutRepoGenericType] = None ) -> Tuple[Callable, List[str]]: """Get output dataclass and column names for result conversion""" if not out_dataclass: @@ -697,8 +697,8 @@ async def is_exists(cls, filter_data: dict) -> bool: @classmethod async def get_first( - cls, filter_data: dict, out_dataclass: Optional[OuterGenericType] = None - ) -> OuterGenericType | None: + cls, filter_data: dict, out_dataclass: Optional[OutRepoGenericType] = None + ) -> OutRepoGenericType | None: """Get the first record matching the filter criteria""" filter_data_ = filter_data.copy() @@ -720,8 +720,8 @@ async def get_list( cls, filter_data: Optional[dict] = None, order_data: Optional[Tuple[str]] = ("id",), - out_dataclass: Optional[OuterGenericType] = None, - ) -> List[OuterGenericType]: + out_dataclass: Optional[OutRepoGenericType] = None, + ) -> List[OutRepoGenericType]: """Get a list of records matching the filter criteria with pagination and ordering""" if not filter_data: filter_data = {} @@ -746,8 +746,8 @@ async def get_list( @classmethod async def create( - cls, data: dict, is_return_require: bool = False, out_dataclass: Optional[OuterGenericType] = None - ) -> OuterGenericType | None: + cls, data: dict, is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + ) -> OutRepoGenericType | None: """Create a single record""" data_copy = data.copy() @@ -766,10 +766,17 @@ async def create( await session.commit() raw = result.fetchone() if raw: - out_entity_, _ = cls.out_dataclass_with_columns(out_dataclass=out_dataclass) + out_entity_, out_cols = cls.out_dataclass_with_columns(out_dataclass=out_dataclass) # Convert Row to dict using column names - entity_data = dict(zip([col.name for col in model_table.columns.values()], raw)) - return out_entity_(**entity_data) + entity_data = dict( + zip( + [ + col.name for col in model_table.columns.values() + ], + raw + ) + ) + return out_entity_(**{k:v for k, v in entity_data.items() if k in out_cols}) else: if explicit_id_provided: # For explicit ID, use insert statement to handle potential conflicts better @@ -788,8 +795,8 @@ async def update( filter_data: dict, data: Dict[str, Any], is_return_require: bool = False, - out_dataclass: Optional[OuterGenericType] = None, - ) -> OuterGenericType | None: + out_dataclass: Optional[OutRepoGenericType] = None, + ) -> OutRepoGenericType | None: """Update records matching the filter criteria""" data_copy = data.copy() @@ -815,8 +822,8 @@ async def update_or_create( filter_data: dict, data: Dict[str, Any], is_return_require: bool = False, - out_dataclass: Optional[OuterGenericType] = None, - ) -> OuterGenericType | None: + out_dataclass: Optional[OutRepoGenericType] = None, + ) -> OutRepoGenericType | None: """Update existing record or create new one if not found""" is_exists = await cls.is_exists(filter_data=filter_data) if is_exists: @@ -851,8 +858,8 @@ async def remove( @classmethod async def create_bulk( - cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OuterGenericType] = None - ) -> List[OuterGenericType] | None: + cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + ) -> List[OutRepoGenericType] | None: """Create multiple records in a single operation""" if not items: return [] @@ -890,8 +897,8 @@ async def create_bulk( @classmethod async def update_bulk( - cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OuterGenericType] = None - ) -> List[OuterGenericType] | None: + cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + ) -> List[OutRepoGenericType] | None: """ Update multiple records in optimized bulk operation. @@ -921,8 +928,8 @@ async def update_bulk( @classmethod async def _bulk_update_with_returning( - cls, session: Any, items: List[dict], out_dataclass: Optional[OuterGenericType] = None - ) -> List[OuterGenericType]: + cls, session: Any, items: List[dict], out_dataclass: Optional[OutRepoGenericType] = None + ) -> List[OutRepoGenericType]: """Perform bulk update with RETURNING for result collection using ORM""" if not items: return [] diff --git a/src/app/interfaces/api/error_handlers.py b/src/app/interfaces/api/error_handlers.py index 29d0f91..6953e78 100644 --- a/src/app/interfaces/api/error_handlers.py +++ b/src/app/interfaces/api/error_handlers.py @@ -50,17 +50,17 @@ def _create_error_resp(exc: AppException, status_code: int) -> JSONResponse: # ========================================== @app.exception_handler(ValidationError) async def exception_handler_validation_error(request: Request, exc: ValidationError) -> JSONResponse: - return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_TYPE) + return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_ENTITY) @app.exception_handler(NotFoundError) async def exception_handler_notfound_error(request: Request, exc: NotFoundError) -> JSONResponse: - return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_TYPE) + return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_ENTITY) @app.exception_handler(AlreadyExistsError) async def exception_handler_already_exists_error(request: Request, exc: AlreadyExistsError) -> JSONResponse: - return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_TYPE) + return _create_error_resp(exc, status.HTTP_422_UNPROCESSABLE_ENTITY) @app.exception_handler(AuthenticationError) @@ -89,7 +89,7 @@ async def exception_handler_fastapi_request_validation_error( "traceback": None, } - kwargs: Dict[str, Any] = {"status_code": status.HTTP_422_UNPROCESSABLE_TYPE, "content": content} + kwargs: Dict[str, Any] = {"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY, "content": content} return JSONResponse(**kwargs) diff --git a/src/app/interfaces/api/v1/endpoints/auth/resources.py b/src/app/interfaces/api/v1/endpoints/auth/resources.py index 6922372..42ab29b 100644 --- a/src/app/interfaces/api/v1/endpoints/auth/resources.py +++ b/src/app/interfaces/api/v1/endpoints/auth/resources.py @@ -16,8 +16,10 @@ @router.post(path="/sign-up/", response_model=SignupResp, name="sign-up") async def sign_up(data: Annotated[SignUpReq, Body()]) -> dict: - user = await app_svc_container.auth_service.create_auth_user(data=data.model_dump()) - + user = await app_svc_container.users_service.create_user_by_email( + email=data.email, + password=data.password, + ) return asdict(user) @@ -27,8 +29,8 @@ async def tokens( ) -> dict: """Get new access, refresh tokens [Based on email, password]""" - user = await app_svc_container.auth_service.get_auth_user_by_phone_number( - phone_number=data.phone_number, verification_code=data.verification_code + user = await app_svc_container.auth_service.get_auth_user_by_email_password( + email=data.email, password=data.password ) token_pair = app_svc_container.auth_service.create_tokens_for_user(uuid=str(user.uuid)) diff --git a/src/app/interfaces/api/v1/endpoints/auth/schemas/req_schemas.py b/src/app/interfaces/api/v1/endpoints/auth/schemas/req_schemas.py index 6b88b28..fa72b88 100644 --- a/src/app/interfaces/api/v1/endpoints/auth/schemas/req_schemas.py +++ b/src/app/interfaces/api/v1/endpoints/auth/schemas/req_schemas.py @@ -12,27 +12,3 @@ class TokenReq(BaseReq): class SignUpReq(BaseReq): email: str password: str - - @field_validator("password", mode="before") - def validate_password(cls, value: Any, values: Any, **kwargs: dict) -> str: # noqa - # Minimum length - if len(value) < 8: - raise ValueError("Password must be at least 8 characters long.") - - # Check for at least one uppercase letter - if not re.search(r"[A-Z]", value): - raise ValueError("Password must contain at least one uppercase letter.") - - # Check for at least one lowercase letter - if not re.search(r"[a-z]", value): - raise ValueError("Password must contain at least one lowercase letter.") - - # Check for at least one digit - if not re.search(r"[0-9]", value): - raise ValueError("Password must contain at least one digit.") - - # Check for at least one special character - if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", value): - raise ValueError("Password must contain at least one special character.") - - return value From 71ea9e17bf26d6d6817a2bc04b60db2e0c53b1be Mon Sep 17 00:00:00 2001 From: medniy2000 Date: Tue, 25 Nov 2025 16:51:11 +0200 Subject: [PATCH 5/8] ddd concepts --- docs/source/ddd_concepts.rst | 1165 ++++++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + 2 files changed, 1166 insertions(+) create mode 100644 docs/source/ddd_concepts.rst diff --git a/docs/source/ddd_concepts.rst b/docs/source/ddd_concepts.rst new file mode 100644 index 0000000..e8ef2db --- /dev/null +++ b/docs/source/ddd_concepts.rst @@ -0,0 +1,1165 @@ +============================================= +Domain-Driven Design (DDD) Reference Guide +============================================= + +Complete reference of DDD building blocks organized by architectural layer. + +---- + +Architecture Layers Overview +============================= + +:: + + ┌─────────────────────────────────────────┐ + │ Interface/Presentation Layer │ ← User-facing (API endpoints, controllers) + ├─────────────────────────────────────────┤ + │ Application Layer │ ← Use case orchestration + ├─────────────────────────────────────────┤ + │ Domain Layer │ ← Business logic & rules (Core) + ├─────────────────────────────────────────┤ + │ Infrastructure Layer │ ← Technical implementation + └─────────────────────────────────────────┘ + +---- + +1. Domain Layer (Core Business Logic) +====================================== + +The heart of the application. Contains business rules, entities, and domain logic. **Should have no dependencies on other layers.** + ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| Building Block | Description | When to Use | Key Characteristics | ++========================+==================================================+============================================+=======================================================+ +| **Entity** | Object with unique identity that persists | When you need to track something | - Has unique ID | +| | over time | through state changes | - Mutable | +| | | | - Identity-based equality | +| | | | - Contains business logic | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Value Object** | Immutable object defined by its attributes | Describing characteristics without | - No unique ID | +| | | identity | - Immutable | +| | | | - Value-based equality | +| | | | - Self-validating | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Aggregate** | Cluster of entities/VOs treated as a unit | Enforcing consistency boundaries | - Contains Aggregate Root | +| | for data changes | | - Transaction boundary | +| | | | - Enforces invariants | +| | | | - Emits Domain Events | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Aggregate Root** | Entry point entity to an aggregate | Controlling access to aggregate internals | - Special entity | +| | | | - Gateway to aggregate | +| | | | - Ensures consistency | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Domain Event** | Record of something that happened in the domain | Communicating state changes | - Immutable | +| | | | - Past tense naming | +| | | | - Contains event data | +| | | | - Timestamp | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Domain Service** | Stateless operation that doesn't belong | Multi-entity operations or pure | - Stateless | +| | to an entity | calculations | - Pure domain logic | +| | | | - No infrastructure concerns | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Repository** | Abstract persistence contract for aggregates | Defining how aggregates are persisted | - Interface only (no implementation) | +| **Interface** | | | - Aggregate-focused | +| | | | - Collection-like API | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Factory** | Creates complex domain objects | Complex object construction with rules | - Encapsulates creation logic | +| | | | - Enforces invariants | +| | | | - Multiple creation paths | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Specification** | Encapsulated business rule | Reusable validation/filtering rules | - Boolean logic | +| | | | - Composable | +| | | | - Declarative | ++------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ + +Domain Layer Examples +--------------------- + +Entity Example +^^^^^^^^^^^^^^ + +.. code-block:: python + + from dataclasses import dataclass + from datetime import datetime + + @dataclass + class OrderItem: + """Entity: Has identity (id), can change quantity""" + id: int # Identity + order_id: int + product_id: int + quantity: int + unit_price: float + + def increase_quantity(self, amount: int) -> None: + """Business logic: increase quantity""" + if amount <= 0: + raise ValueError("Amount must be positive") + self.quantity += amount + + def calculate_total(self) -> float: + """Business logic: calculate line total""" + return self.quantity * self.unit_price + + +Value Object Example +^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from dataclasses import dataclass + + @dataclass(frozen=True) + class Email: + """Value Object: Immutable, validated email""" + value: str + + def __post_init__(self): + if "@" not in self.value or "." not in self.value: + raise ValueError(f"Invalid email: {self.value}") + + def domain(self) -> str: + return self.value.split("@")[1] + + @dataclass(frozen=True) + class Money: + """Value Object: Amount with currency""" + amount: float + currency: str = "USD" + + def add(self, other: "Money") -> "Money": + if self.currency != other.currency: + raise ValueError("Cannot add different currencies") + return Money(self.amount + other.amount, self.currency) + + +Aggregate Example +^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from dataclasses import dataclass, field + from typing import List + from datetime import datetime + + @dataclass + class Order(BaseAggregate): # Aggregate Root + """Order Aggregate: manages OrderItems, enforces business rules""" + id: int + customer_id: int + status: str + created_at: datetime + items: List[OrderItem] = field(default_factory=list) + + def add_item(self, product_id: int, quantity: int, price: float) -> None: + """Business rule: Add item and emit event""" + if self.status == "completed": + raise ValueError("Cannot modify completed order") + + # Check if item already exists + for item in self.items: + if item.product_id == product_id: + item.increase_quantity(quantity) + return + + # Add new item + item = OrderItem( + id=len(self.items) + 1, + order_id=self.id, + product_id=product_id, + quantity=quantity, + unit_price=price + ) + self.items.append(item) + + # Emit domain event + self.add_event(OrderItemAddedEvent( + order_id=self.id, + product_id=product_id, + quantity=quantity + )) + + def calculate_total(self) -> float: + """Business rule: Calculate order total""" + return sum(item.calculate_total() for item in self.items) + + def complete(self) -> None: + """Business rule: Complete order""" + if not self.items: + raise ValueError("Cannot complete empty order") + if self.status == "completed": + raise ValueError("Order already completed") + + self.status = "completed" + self.add_event(OrderCompletedEvent(order_id=self.id)) + + +Domain Event Example +^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from dataclasses import dataclass + from datetime import datetime + + @dataclass(frozen=True) + class UserCreatedEvent(DomainEvent): + """Event: User was created""" + user_uuid: str + email: str + created_at: datetime + + @dataclass(frozen=True) + class OrderCompletedEvent(DomainEvent): + """Event: Order was completed""" + order_id: int + completed_at: datetime + total_amount: float + + +Domain Service Example +^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + class OrderPricingService: + """Domain Service: Pricing doesn't belong to Order or Product alone""" + + def calculate_discounted_price( + self, + order: Order, + customer: Customer, + discount_rules: List[DiscountRule] + ) -> Money: + """Calculate price with discounts based on customer and order""" + base_price = order.calculate_total() + + # Apply customer-specific discounts + discount = 0.0 + for rule in discount_rules: + if rule.applies_to(customer, order): + discount += rule.calculate_discount(base_price) + + return Money(base_price - discount) + + class PasswordHashingService: + """Domain Service: Password hashing is pure domain logic""" + + def hash_password(self, raw_password: str) -> str: + """Hash password using bcrypt""" + import bcrypt + return bcrypt.hashpw(raw_password.encode(), bcrypt.gensalt()).decode() + + def verify_password(self, raw_password: str, hashed: str) -> bool: + """Verify password against hash""" + import bcrypt + return bcrypt.checkpw(raw_password.encode(), hashed.encode()) + + +Repository Interface Example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from abc import ABC, abstractmethod + from typing import Optional, List + + class IUserRepository(ABC): + """Repository Interface: Defined in domain, implemented in infrastructure""" + + @abstractmethod + async def get_by_id(self, user_id: int) -> Optional[UserAggregate]: + """Get user by ID""" + pass + + @abstractmethod + async def get_by_email(self, email: str) -> Optional[UserAggregate]: + """Get user by email""" + pass + + @abstractmethod + async def save(self, user: UserAggregate) -> None: + """Save user aggregate""" + pass + + @abstractmethod + async def delete(self, user_id: int) -> None: + """Delete user""" + pass + + @abstractmethod + async def list_all(self, skip: int = 0, limit: int = 100) -> List[UserAggregate]: + """List all users with pagination""" + pass + + +Factory Example +^^^^^^^^^^^^^^^ + +.. code-block:: python + + from uuid import uuid4 + from datetime import datetime + + class UserFactory: + """Factory: Complex user creation with different paths""" + + @staticmethod + def create_guest() -> UserAggregate: + """Create guest user with defaults""" + return UserAggregate( + id=0, + uuid=str(uuid4()), + email=f"guest_{uuid4().hex[:8]}@temp.com", + is_guest=True, + is_active=True, + created_at=datetime.now(), + updated_at=datetime.now(), + password_hashed=None, + meta={"source": "guest_creation"} + ) + + @staticmethod + def create_from_registration( + email: str, + password_hashed: str, + first_name: str, + last_name: str + ) -> UserAggregate: + """Create user from registration with validation""" + if not email or "@" not in email: + raise ValueError("Invalid email") + + return UserAggregate( + id=0, + uuid=str(uuid4()), + email=email, + password_hashed=password_hashed, + first_name=first_name, + last_name=last_name, + is_guest=False, + is_active=True, + created_at=datetime.now(), + updated_at=datetime.now(), + meta={"source": "registration"} + ) + + @staticmethod + def create_from_oauth( + provider: str, + external_id: str, + email: str, + profile_data: dict + ) -> UserAggregate: + """Create user from OAuth provider""" + return UserAggregate( + id=0, + uuid=str(uuid4()), + email=email, + password_hashed=None, # No password for OAuth + first_name=profile_data.get("first_name"), + last_name=profile_data.get("last_name"), + photo=profile_data.get("picture"), + is_guest=False, + is_active=True, + created_at=datetime.now(), + updated_at=datetime.now(), + meta={ + "source": "oauth", + "provider": provider, + "external_id": external_id + } + ) + + +Specification Example +^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from abc import ABC, abstractmethod + + class Specification(ABC): + """Base specification pattern""" + + @abstractmethod + def is_satisfied_by(self, candidate) -> bool: + pass + + def and_(self, other: "Specification") -> "AndSpecification": + return AndSpecification(self, other) + + def or_(self, other: "Specification") -> "OrSpecification": + return OrSpecification(self, other) + + def not_(self) -> "NotSpecification": + return NotSpecification(self) + + + class UserIsActiveSpec(Specification): + """Check if user is active""" + + def is_satisfied_by(self, user: UserAggregate) -> bool: + return user.is_active and not user.is_guest + + + class UserCanPostSpec(Specification): + """Check if user can create posts""" + + def __init__(self, min_account_age_days: int = 1): + self.min_account_age_days = min_account_age_days + + def is_satisfied_by(self, user: UserAggregate) -> bool: + from datetime import datetime, timedelta + + is_active = UserIsActiveSpec().is_satisfied_by(user) + is_old_enough = ( + datetime.now() - user.created_at + >= timedelta(days=self.min_account_age_days) + ) + + return is_active and is_old_enough + + + class AndSpecification(Specification): + """Composite AND specification""" + + def __init__(self, left: Specification, right: Specification): + self.left = left + self.right = right + + def is_satisfied_by(self, candidate) -> bool: + return self.left.is_satisfied_by(candidate) and self.right.is_satisfied_by(candidate) + + +---- + +2. Application Layer (Use Case Orchestration) +============================================== + +Coordinates domain objects to fulfill use cases. **Depends on domain layer, not infrastructure.** + ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| Building Block | Description | When to Use | Key Characteristics | ++===========================+==================================================+============================================+=======================================================+ +| **Application Service** | Orchestrates use cases using domain objects | Every user-facing feature/use case | - Stateless | +| | | | - Transaction boundaries | +| | | | - Coordinates domain objects | +| | | | - No business logic | +| | | | - Handles events | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **DTO (Data Transfer** | Data container for transferring data | Moving data across boundaries | - No behavior | +| **Object)** | between layers | | - Validation rules | +| | | | - Serialization | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Command** | Request to change system state | CQRS pattern, async processing | - Intent-revealing name | +| | | | - Immutable | +| | | | - Contains all data needed | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Query** | Request to read data | CQRS pattern, read-only operations | - No side effects | +| | | | - Returns DTOs | +| | | | - Optimized for reads | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Command Handler** | Executes a command | Processing commands in CQRS | - One handler per command | +| | | | - Contains use case logic | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Query Handler** | Executes a query | Processing queries in CQRS | - One handler per query | +| | | | - Read-only operations | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ + +Application Layer Examples +-------------------------- + +Application Service Example (Real from your project) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + class AppAuthService(AbstractBaseApplicationService): + """Application Service: Orchestrates authentication use cases""" + + # Dependencies on domain services + dom_users_svc_container: DomainUsersServiceContainer + dom_auth_svc_container: DomainAuthServiceContainer + + @classmethod + async def create_auth_user(cls, data: dict) -> Any: + """Use case: Register new user""" + # 1. Validate email + email = data.get("email") or "" + email_validated = validate_email(email)[1] + + # 2. Check if exists (application logic) + is_exists = await cls.app_svc_container.users_service.is_exists( + filter_data={"email": email_validated} + ) + if is_exists: + raise AlreadyExistsError(message="User already exists") + + # 3. Hash password (domain service) + password = data.pop("password", None) or "" + password_hashed = cls.dom_auth_svc_container.auth_service.get_password_hashed(password) + data["password_hashed"] = password_hashed + + # 4. Create user (repository) + return await cls.app_svc_container.users_service.create(data) + + @classmethod + async def authenticate_user(cls, email: str, password: str) -> tuple[Any, TokenPair]: + """Use case: Authenticate user and return tokens""" + # 1. Validate email + email_validated = validate_email(email)[1] + + # 2. Get user (repository) + user = await cls.app_svc_container.users_service.get_first( + filter_data={"email": email_validated} + ) + + # 3. Verify password (domain service) + is_valid = cls.dom_auth_svc_container.auth_service.verify_password( + password, user.password_hashed + ) + if not user or not is_valid: + raise ValidationError(message="Invalid credentials") + + # 4. Create tokens (domain service) + tokens = cls.dom_auth_svc_container.jwt_service.create_token_pair(user.uuid) + + return user, tokens + + +Command/Query Examples (CQRS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from dataclasses import dataclass + + # Commands (write operations) + @dataclass(frozen=True) + class CreateUserCommand: + """Command: Create a new user""" + email: str + password: str + first_name: str + last_name: str + + @dataclass(frozen=True) + class UpdateUserProfileCommand: + """Command: Update user profile""" + user_id: int + first_name: str | None = None + last_name: str | None = None + phone: str | None = None + + # Queries (read operations) + @dataclass(frozen=True) + class GetUserByIdQuery: + """Query: Get user by ID""" + user_id: int + + @dataclass(frozen=True) + class ListUsersQuery: + """Query: List users with filters""" + is_active: bool | None = None + skip: int = 0 + limit: int = 100 + + + # Command Handler + class CreateUserCommandHandler: + """Handle CreateUserCommand""" + + def __init__(self, user_repo: IUserRepository, auth_service: PasswordHashingService): + self.user_repo = user_repo + self.auth_service = auth_service + + async def handle(self, command: CreateUserCommand) -> int: + """Execute command and return user ID""" + # Hash password + password_hashed = self.auth_service.hash_password(command.password) + + # Create user aggregate + user = UserFactory.create_from_registration( + email=command.email, + password_hashed=password_hashed, + first_name=command.first_name, + last_name=command.last_name + ) + + # Save to repository + await self.user_repo.save(user) + + # Publish events + for event in user.get_events(): + await event_publisher.publish(event) + user.events_clear() + + return user.id + + + # Query Handler + class GetUserByIdQueryHandler: + """Handle GetUserByIdQuery""" + + def __init__(self, user_repo: IUserRepository): + self.user_repo = user_repo + + async def handle(self, query: GetUserByIdQuery) -> UserDTO: + """Execute query and return DTO""" + user = await self.user_repo.get_by_id(query.user_id) + if not user: + raise NotFoundError(f"User {query.user_id} not found") + + return UserDTO.from_aggregate(user) + + +DTO Example +^^^^^^^^^^^ + +.. code-block:: python + + from dataclasses import dataclass + from datetime import datetime + + @dataclass + class UserDTO: + """DTO: Transfer user data to presentation layer""" + id: int + uuid: str + email: str + first_name: str | None + last_name: str | None + is_active: bool + created_at: datetime + + @classmethod + def from_aggregate(cls, user: UserAggregate) -> "UserDTO": + """Convert aggregate to DTO""" + return cls( + id=user.id, + uuid=user.uuid, + email=user.email, + first_name=user.first_name, + last_name=user.last_name, + is_active=user.is_active, + created_at=user.created_at + ) + + def to_dict(self) -> dict: + """Serialize for API response""" + return { + "id": self.id, + "uuid": self.uuid, + "email": self.email, + "first_name": self.first_name, + "last_name": self.last_name, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() + } + + +---- + +3. Infrastructure Layer (Technical Implementation) +================================================== + +Provides technical capabilities. **Implements interfaces defined in domain layer.** + ++-------------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| Building Block | Description | When to Use | Key Characteristics | ++===============================+==================================================+============================================+=======================================================+ +| **Repository** | Concrete persistence implementation | Implementing domain repository interfaces | - Database access | +| **Implementation** | | | - ORM mapping | +| | | | - Query optimization | ++-------------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Infrastructure Service** | Technical services (email, SMS, logging) | External integrations | - No domain logic | +| | | | - Adapters to external systems | ++-------------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Event Publisher** | Publishes domain events to message bus | Event-driven architecture | - Message broker integration | +| | | | - Async processing | ++-------------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Mapper/Adapter** | Converts between domain and infrastructure | Translating between layers | - ORM entities ↔ Aggregates | +| | models | | - External API ↔ Domain | ++-------------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ + +Infrastructure Layer Examples +----------------------------- + +Repository Implementation Example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy import select + from typing import Optional, List + + class UserRepository(IUserRepository): + """Repository Implementation: SQLAlchemy-based persistence""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def get_by_id(self, user_id: int) -> Optional[UserAggregate]: + """Get user from database""" + # Query ORM model + result = await self.session.execute( + select(UserModel).where(UserModel.id == user_id) + ) + user_model = result.scalar_one_or_none() + + if not user_model: + return None + + # Convert ORM model to aggregate + return self._to_aggregate(user_model) + + async def save(self, user: UserAggregate) -> None: + """Save user to database""" + # Convert aggregate to ORM model + user_model = self._to_model(user) + + # Save to database + self.session.add(user_model) + await self.session.flush() + + # Update aggregate ID if new + if user.id == 0: + user.id = user_model.id + + async def list_all(self, skip: int = 0, limit: int = 100) -> List[UserAggregate]: + """List users with pagination""" + result = await self.session.execute( + select(UserModel).offset(skip).limit(limit) + ) + user_models = result.scalars().all() + + return [self._to_aggregate(model) for model in user_models] + + def _to_aggregate(self, model: UserModel) -> UserAggregate: + """Map ORM model to domain aggregate""" + return UserAggregate( + id=model.id, + uuid=model.uuid, + email=model.email, + password_hashed=model.password_hashed, + first_name=model.first_name, + last_name=model.last_name, + is_active=model.is_active, + is_guest=model.is_guest, + created_at=model.created_at, + updated_at=model.updated_at, + meta=model.meta or {} + ) + + def _to_model(self, aggregate: UserAggregate) -> UserModel: + """Map domain aggregate to ORM model""" + return UserModel( + id=aggregate.id if aggregate.id else None, + uuid=aggregate.uuid, + email=aggregate.email, + password_hashed=aggregate.password_hashed, + first_name=aggregate.first_name, + last_name=aggregate.last_name, + is_active=aggregate.is_active, + is_guest=aggregate.is_guest, + meta=aggregate.meta + ) + + +Infrastructure Service Example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + class EmailService: + """Infrastructure Service: Send emails via external provider""" + + def __init__(self, smtp_config: dict): + self.smtp_config = smtp_config + + async def send_email(self, to: str, subject: str, body: str) -> None: + """Send email using SMTP""" + import aiosmtplib + from email.message import EmailMessage + + message = EmailMessage() + message["From"] = self.smtp_config["from"] + message["To"] = to + message["Subject"] = subject + message.set_content(body) + + await aiosmtplib.send( + message, + hostname=self.smtp_config["host"], + port=self.smtp_config["port"], + username=self.smtp_config["username"], + password=self.smtp_config["password"] + ) + + + class SMSService: + """Infrastructure Service: Send SMS via Twilio""" + + def __init__(self, twilio_client): + self.client = twilio_client + + async def send_sms(self, to: str, message: str) -> None: + """Send SMS using Twilio""" + await self.client.messages.create( + to=to, + from_="+1234567890", + body=message + ) + + +Event Publisher Example +^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + class RabbitMQEventPublisher: + """Event Publisher: Publish domain events to RabbitMQ""" + + def __init__(self, connection): + self.connection = connection + + async def publish(self, event: DomainEvent) -> None: + """Publish event to message queue""" + import json + + channel = await self.connection.channel() + + # Serialize event + event_data = { + "event_id": event.id, + "event_type": event.__class__.__name__, + "occurred_at": event.occurred_at.isoformat(), + "payload": event.to_dict() + } + + # Publish to exchange + await channel.basic_publish( + exchange="domain_events", + routing_key=event.__class__.__name__, + body=json.dumps(event_data).encode() + ) + + +---- + +4. Interface/Presentation Layer (User Interface) +================================================= + +Handles user interaction. **Depends on application layer.** + ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| Building Block | Description | When to Use | Key Characteristics | ++===========================+==================================================+============================================+=======================================================+ +| **Controller/Endpoint** | Handles HTTP requests | Web APIs, REST endpoints | - Request validation | +| | | | - Calls application services | +| | | | - Returns responses | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **View Model** | Data prepared for UI display | Presenting data to users | - UI-specific structure | +| | | | - Formatted data | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ +| **Request/Response** | API contract definitions | API endpoints | - Input validation | +| **Models** | | | - Serialization | ++---------------------------+--------------------------------------------------+--------------------------------------------+-------------------------------------------------------+ + +Interface Layer Examples +------------------------ + +API Controller Example +^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from fastapi import APIRouter, Depends, HTTPException, status + from pydantic import BaseModel + + router = APIRouter(prefix="/api/users") + + + # Request/Response Models + class RegisterUserRequest(BaseModel): + """API Request: User registration""" + email: str + password: str + first_name: str + last_name: str + + + class UserResponse(BaseModel): + """API Response: User data""" + id: int + uuid: str + email: str + first_name: str | None + last_name: str | None + is_active: bool + + + class TokenResponse(BaseModel): + """API Response: Authentication tokens""" + access_token: str + refresh_token: str + token_type: str = "bearer" + + + # Controllers/Endpoints + @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) + async def register_user( + request: RegisterUserRequest, + auth_service: AppAuthService = Depends() + ): + """Endpoint: Register new user""" + try: + # Call application service + user = await auth_service.create_auth_user(request.dict()) + + # Return response + return UserResponse( + id=user.id, + uuid=user.uuid, + email=user.email, + first_name=user.first_name, + last_name=user.last_name, + is_active=user.is_active + ) + except AlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) + + + @router.post("/login", response_model=TokenResponse) + async def login( + email: str, + password: str, + auth_service: AppAuthService = Depends() + ): + """Endpoint: Authenticate user""" + try: + # Call application service + user, tokens = await auth_service.authenticate_user(email, password) + + # Return tokens + return TokenResponse( + access_token=tokens.access_token, + refresh_token=tokens.refresh_token + ) + except ValidationError as e: + raise HTTPException(status_code=401, detail="Invalid credentials") + + +---- + +Layer Dependencies +================== + +:: + + Interface Layer + ↓ depends on + Application Layer + ↓ depends on + Domain Layer ← Core (no dependencies) + ↑ implemented by + Infrastructure Layer + +**Key Rules:** + +- Domain layer has NO dependencies on other layers +- Infrastructure implements domain interfaces +- Application orchestrates domain objects +- Interface calls application services + +---- + +Quick Decision Guide +==================== + +"Where does this code belong?" +------------------------------ + ++--------------------------------------------------------+--------+------------------+ +| Question | Answer | Layer | ++========================================================+========+==================+ +| Is it a business rule or concept? | Yes | **Domain** | ++--------------------------------------------------------+--------+------------------+ +| Does it coordinate multiple domain objects? | Yes | **Application** | ++--------------------------------------------------------+--------+------------------+ +| Does it touch a database or external API? | Yes | **Infrastructure**| ++--------------------------------------------------------+--------+------------------+ +| Does it handle HTTP requests/responses? | Yes | **Interface** | ++--------------------------------------------------------+--------+------------------+ + +"Which building block do I use?" +-------------------------------- + ++--------------------------------------------------------+---------------------------+ +| Need | Use | ++========================================================+===========================+ +| Track something with identity that changes | **Entity** | ++--------------------------------------------------------+---------------------------+ +| Describe something immutable | **Value Object** | ++--------------------------------------------------------+---------------------------+ +| Group related entities with consistency rules | **Aggregate** | ++--------------------------------------------------------+---------------------------+ +| Record something that happened | **Domain Event** | ++--------------------------------------------------------+---------------------------+ +| Multi-entity business logic | **Domain Service** | ++--------------------------------------------------------+---------------------------+ +| Orchestrate a use case | **Application Service** | ++--------------------------------------------------------+---------------------------+ +| Save/load aggregates | **Repository** | ++--------------------------------------------------------+---------------------------+ +| Complex object creation | **Factory** | ++--------------------------------------------------------+---------------------------+ +| Reusable business rule | **Specification** | ++--------------------------------------------------------+---------------------------+ + +---- + +Common Patterns +=============== + +Pattern: Aggregate with Events +------------------------------- + +.. code-block:: python + + @dataclass + class User(BaseAggregate): + id: int + email: str + password_hashed: str + + def change_password(self, new_password_hashed: str) -> None: + """Change password and emit event""" + old_hash = self.password_hashed + self.password_hashed = new_password_hashed + + # Emit event + self.add_event(UserPasswordChangedEvent( + user_id=self.id, + changed_at=datetime.now() + )) + + +Pattern: Application Service with Transaction +---------------------------------------------- + +.. code-block:: python + + class OrderApplicationService: + def __init__( + self, + order_repo: IOrderRepository, + inventory_service: InventoryService, + event_publisher: EventPublisher + ): + self.order_repo = order_repo + self.inventory_service = inventory_service + self.event_publisher = event_publisher + + async def place_order(self, command: PlaceOrderCommand) -> int: + """Use case: Place order (transactional)""" + async with transaction(): + # 1. Create order aggregate + order = OrderFactory.create_new(command.customer_id) + + # 2. Add items + for item in command.items: + order.add_item(item.product_id, item.quantity, item.price) + + # 3. Reserve inventory (domain service) + for item in order.items: + await self.inventory_service.reserve(item.product_id, item.quantity) + + # 4. Complete order + order.complete() + + # 5. Save aggregate + await self.order_repo.save(order) + + # 6. Publish events + for event in order.get_events(): + await self.event_publisher.publish(event) + order.events_clear() + + return order.id + + +Pattern: Repository with Mapper +-------------------------------- + +.. code-block:: python + + class ProductRepository(IProductRepository): + def __init__(self, session: AsyncSession): + self.session = session + self.mapper = ProductMapper() + + async def get_by_id(self, product_id: int) -> Optional[Product]: + model = await self.session.get(ProductModel, product_id) + return self.mapper.to_domain(model) if model else None + + async def save(self, product: Product) -> None: + model = self.mapper.to_persistence(product) + self.session.add(model) + + + class ProductMapper: + """Mapper: Convert between domain and persistence models""" + + def to_domain(self, model: ProductModel) -> Product: + return Product( + id=model.id, + name=model.name, + price=Money(model.price, model.currency), + stock=model.stock + ) + + def to_persistence(self, product: Product) -> ProductModel: + return ProductModel( + id=product.id, + name=product.name, + price=product.price.amount, + currency=product.price.currency, + stock=product.stock + ) + + +---- + +Anti-Patterns to Avoid +======================= + ++--------------------------------+--------------------------------------------------+------------------------------------------------------+ +| Anti-Pattern | Problem | Solution | ++================================+==================================================+======================================================+ +| Anemic Domain Model | Entities with only getters/setters, no behavior | Move logic from services into entities | ++--------------------------------+--------------------------------------------------+------------------------------------------------------+ +| God Object | One huge entity doing everything | Split into multiple aggregates | ++--------------------------------+--------------------------------------------------+------------------------------------------------------+ +| Domain Logic in Controllers | Business rules in API layer | Move to domain services/entities | ++--------------------------------+--------------------------------------------------+------------------------------------------------------+ +| Repository in Domain Services | Domain depending on infrastructure | Inject repository interface | ++--------------------------------+--------------------------------------------------+------------------------------------------------------+ +| Mutable Value Objects | Value objects that can change | Make them immutable (frozen) | ++--------------------------------+--------------------------------------------------+------------------------------------------------------+ +| Large Aggregates | Aggregate with 100+ entities | Split into separate aggregates | ++--------------------------------+--------------------------------------------------+------------------------------------------------------+ +| Cross-Aggregate Transactions | Modifying multiple aggregates in one transaction | Use eventual consistency + events | ++--------------------------------+--------------------------------------------------+------------------------------------------------------+ + +---- + +Further Reading +=============== + +- **Book**: "Domain-Driven Design" by Eric Evans (Blue Book) +- **Book**: "Implementing Domain-Driven Design" by Vaughn Vernon (Red Book) +- **Book**: "Domain-Driven Design Distilled" by Vaughn Vernon (Quick intro) \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 854eac9..c1aef87 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,3 +10,4 @@ Welcome to documentation! base_commands env_variables db_structure + ddd_concepts From 1acb57adca603ffd33c4c537b4ec402da59854bd Mon Sep 17 00:00:00 2001 From: medniy2000 Date: Tue, 25 Nov 2025 17:06:03 +0200 Subject: [PATCH 6/8] improve repository --- .../repositories/base/base_psql_repository.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/app/infrastructure/repositories/base/base_psql_repository.py b/src/app/infrastructure/repositories/base/base_psql_repository.py index 41cab01..4d1c670 100644 --- a/src/app/infrastructure/repositories/base/base_psql_repository.py +++ b/src/app/infrastructure/repositories/base/base_psql_repository.py @@ -767,16 +767,14 @@ async def create( raw = result.fetchone() if raw: out_entity_, out_cols = cls.out_dataclass_with_columns(out_dataclass=out_dataclass) - # Convert Row to dict using column names - entity_data = dict( - zip( - [ - col.name for col in model_table.columns.values() - ], - raw - ) - ) - return out_entity_(**{k:v for k, v in entity_data.items() if k in out_cols}) + # Convert Row to dict using only needed columns (O(1) lookup with set) + out_cols_set = set(out_cols) + column_names = [col.name for col in model_table.columns.values()] + return out_entity_(**{ + col_name: value + for col_name, value in zip(column_names, raw) + if col_name in out_cols_set + }) else: if explicit_id_provided: # For explicit ID, use insert statement to handle potential conflicts better From 1e59e4696b2b7071e558a643cb44963d1ad2a6b8 Mon Sep 17 00:00:00 2001 From: medniy2000 Date: Tue, 25 Nov 2025 17:45:44 +0200 Subject: [PATCH 7/8] after beautify --- .flake8 | 2 + src/app/application/common/dto/base.py | 6 +- src/app/application/common/services/base.py | 70 ++++++++----------- src/app/application/dto/user.py | 2 +- src/app/application/services/auth_service.py | 7 +- src/app/application/services/users_service.py | 10 +-- src/app/domain/common/aggregates/base.py | 2 +- src/app/domain/common/events/base.py | 2 +- src/app/domain/users/container.py | 1 + .../domain/users/services/users_service.py | 2 +- .../domain/users/value_objects/users_vob.py | 33 +++------ .../repositories/base/abstract.py | 23 ++++-- .../repositories/base/base_psql_repository.py | 36 ++++++---- src/app/interfaces/api/core/dependencies.py | 2 +- .../api/v1/endpoints/auth/resources.py | 2 +- .../v1/endpoints/auth/schemas/req_schemas.py | 3 - 16 files changed, 98 insertions(+), 105 deletions(-) diff --git a/.flake8 b/.flake8 index 94d9bd1..3079b5b 100644 --- a/.flake8 +++ b/.flake8 @@ -2,3 +2,5 @@ exclude = .git,__pycache__, *env,.venv,*venv,migrations,logs,src/app/interfaces/grpc max-line-length = 115 max-complexity = 8 +# E203: whitespace before ':' - conflicts with Black formatter +extend-ignore = E203 diff --git a/src/app/application/common/dto/base.py b/src/app/application/common/dto/base.py index 8698754..64df104 100644 --- a/src/app/application/common/dto/base.py +++ b/src/app/application/common/dto/base.py @@ -1,6 +1,8 @@ from dataclasses import dataclass +from src.app.application.common.services.base import BaseSvcOutEntity + @dataclass -class AppBaseDTO: - pass \ No newline at end of file +class AppBaseDTO(BaseSvcOutEntity): + pass diff --git a/src/app/application/common/services/base.py b/src/app/application/common/services/base.py index 8f98ff1..4376e49 100644 --- a/src/app/application/common/services/base.py +++ b/src/app/application/common/services/base.py @@ -2,15 +2,17 @@ from dataclasses import dataclass from typing import Any, Dict, Generic, List, Optional, Tuple, Type, TypeVar -from src.app.infrastructure.repositories.base.abstract import AbstractBaseRepository +from src.app.infrastructure.repositories.base.abstract import AbstractBaseRepository, BaseOutEntity @dataclass -class BaseSvcOutEntity(ABC): +class BaseSvcOutEntity(BaseOutEntity): pass + OutSvcGenericType = TypeVar("OutSvcGenericType", bound=BaseSvcOutEntity) + class AbstractBaseApplicationService(ABC): pass @@ -26,29 +28,29 @@ async def is_exists(cls, filter_data: dict) -> bool: @classmethod async def get_first( - cls, - filter_data: dict, - out_dataclass: Optional[Type[OutSvcGenericType]] = None, + cls, + filter_data: dict, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> OutSvcGenericType | None: raise NotImplementedError @classmethod async def get_list( cls, - filter_data: dict, - offset: int = 0, - limit: Optional[int] = None, - order_data: Tuple[str] = ("id",), - out_dataclass: Optional[Type[OutSvcGenericType]] = None, + filter_data: dict, + offset: int = 0, + limit: Optional[int] = None, + order_data: Tuple[str] = ("id",), + out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> List[OutSvcGenericType]: raise NotImplementedError @classmethod async def create( - cls, - data: dict, - is_return_require: bool = False, - out_dataclass: Optional[Type[OutSvcGenericType]] = None, + cls, + data: dict, + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> OutSvcGenericType | None: raise NotImplementedError @@ -111,9 +113,9 @@ async def is_exists(cls, filter_data: dict) -> bool: @classmethod async def get_first( - cls, - filter_data: dict, - out_dataclass: Optional[Type[OutSvcGenericType]] = None, + cls, + filter_data: dict, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> OutSvcGenericType | None: item = await cls.repository.get_first(filter_data=filter_data, out_dataclass=out_dataclass) return item @@ -132,21 +134,18 @@ async def get_list( if limit is not None: filter_data_["limit"] = limit return await cls.repository.get_list( - filter_data=filter_data_, - order_data=order_data, - out_dataclass=out_dataclass + filter_data=filter_data_, order_data=order_data, out_dataclass=out_dataclass ) @classmethod async def create( - cls, data: dict, - is_return_require: bool = False, - out_dataclass: Optional[Type[OutSvcGenericType]] = None, + cls, + data: dict, + is_return_require: bool = False, + out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> OutSvcGenericType | None: return await cls.repository.create( - data=data, - is_return_require=is_return_require, - out_dataclass=out_dataclass + data=data, is_return_require=is_return_require, out_dataclass=out_dataclass ) @classmethod @@ -157,9 +156,7 @@ async def create_bulk( out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> List[OutSvcGenericType] | None: return await cls.repository.create_bulk( - items=items, - is_return_require=is_return_require, - out_dataclass=out_dataclass + items=items, is_return_require=is_return_require, out_dataclass=out_dataclass ) @classmethod @@ -171,11 +168,7 @@ async def update( out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> OutSvcGenericType | None: return await cls.repository.update( - filter_data=filter_data, - data=data, - is_return_require=is_return_require, - out_dataclass=out_dataclass - + filter_data=filter_data, data=data, is_return_require=is_return_require, out_dataclass=out_dataclass ) @classmethod @@ -186,9 +179,7 @@ async def update_bulk( out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> List[OutSvcGenericType] | None: return await cls.repository.update_bulk( - items=items, - is_return_require=is_return_require, - out_dataclass=out_dataclass + items=items, is_return_require=is_return_require, out_dataclass=out_dataclass ) @classmethod @@ -200,10 +191,7 @@ async def update_or_create( out_dataclass: Optional[Type[OutSvcGenericType]] = None, ) -> OutSvcGenericType | None: return await cls.repository.update_or_create( - filter_data=filter_data, - data=data, - is_return_require=is_return_require, - out_dataclass=out_dataclass + filter_data=filter_data, data=data, is_return_require=is_return_require, out_dataclass=out_dataclass ) @classmethod diff --git a/src/app/application/dto/user.py b/src/app/application/dto/user.py index bae82a3..987c2e7 100644 --- a/src/app/application/dto/user.py +++ b/src/app/application/dto/user.py @@ -14,4 +14,4 @@ class UserShortDTO(AppBaseDTO): last_name: Optional[str] email: Optional[str] phone: Optional[str] - is_active: bool \ No newline at end of file + is_active: bool diff --git a/src/app/application/services/auth_service.py b/src/app/application/services/auth_service.py index eab4cf4..7834190 100644 --- a/src/app/application/services/auth_service.py +++ b/src/app/application/services/auth_service.py @@ -20,7 +20,6 @@ class AppAuthService(AbstractBaseApplicationService): dom_users_svc_container: DomainUsersServiceContainer = domain_users_svc_container dom_auth_svc_container: DomainAuthServiceContainer = domain_auth_svc_container - @classmethod async def get_auth_user_by_email_password(cls, email: str, password: str) -> Any: try: @@ -65,8 +64,8 @@ async def refresh_tokens(cls, refresh_token: str) -> tuple[UserShortDTO, TokenPa """Verify refresh token, get user, and create new token pair.""" decoded = cls.verify_refresh_token(refresh_token) user = await cls.app_svc_container.users_service.get_first( - filter_data={"uuid": decoded.uuid}, - out_dataclass=UserShortDTO + filter_data={"uuid": decoded.uuid}, out_dataclass=UserShortDTO ) - new_tokens = cls.create_tokens_for_user(str(getattr(user, "uuid", ""))) + assert user is not None + new_tokens = cls.create_tokens_for_user(str(user.uuid)) return user, new_tokens diff --git a/src/app/application/services/users_service.py b/src/app/application/services/users_service.py index b7931f6..9778ec9 100644 --- a/src/app/application/services/users_service.py +++ b/src/app/application/services/users_service.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import validate_email from src.app.application.common.services.base import BaseApplicationService @@ -24,7 +26,7 @@ class AppUserService(BaseApplicationService): repository = repo_container.users_repository @classmethod - async def create_user_by_email(cls, email: str, password: str) -> UserShortDTO: + async def create_user_by_email(cls, email: str, password: str) -> Optional[UserShortDTO]: _, validated_email = validate_email(email) validated_data = EmailPasswordPair(email=validated_email, password=password) password_hashed = domain_auth_svc_container.auth_service.get_password_hashed(password=password) @@ -43,12 +45,12 @@ async def create_user_by_email(cls, email: str, password: str) -> UserShortDTO: "email": validated_data.email, "password_hashed": password_hashed, } - user_dto = await cls.create(data, is_return_require=True, out_dataclass=UserShortDTO) - return user_dto + user_dto = await cls.create(data, is_return_require=True, out_dataclass=UserShortDTO) + return user_dto @classmethod - async def create_user_by_phone(cls, phone: str, verification_code: str) -> UserShortDTO: + async def create_user_by_phone(cls, phone: str, verification_code: str) -> Optional[UserShortDTO]: validated_data = PhoneNumberCodePair(phone=phone, verification_code=verification_code) is_phone_exists = await cls.app_svc_container.users_service.is_exists( filter_data={"phone": validated_data.phone} diff --git a/src/app/domain/common/aggregates/base.py b/src/app/domain/common/aggregates/base.py index 833abff..93c4c39 100644 --- a/src/app/domain/common/aggregates/base.py +++ b/src/app/domain/common/aggregates/base.py @@ -22,7 +22,7 @@ def get_events(self) -> List[DomainEvent]: def events_load(self, raw_events: List[dict]) -> List[DomainEvent]: """Get all domain events.""" - pass + raise NotImplementedError("Subclasses must implement events_load method") def has_events(self) -> bool: """Check if aggregate has any events.""" diff --git a/src/app/domain/common/events/base.py b/src/app/domain/common/events/base.py index 828c971..915e12b 100644 --- a/src/app/domain/common/events/base.py +++ b/src/app/domain/common/events/base.py @@ -19,4 +19,4 @@ def to_dict(self) -> dict: "created_at": self.created_at, "event": self.event, "payload": self.payload, - } \ No newline at end of file + } diff --git a/src/app/domain/users/container.py b/src/app/domain/users/container.py index 1e7a7b6..ae64806 100644 --- a/src/app/domain/users/container.py +++ b/src/app/domain/users/container.py @@ -9,6 +9,7 @@ class DomainUsersServiceContainer(DomainBaseServicesContainer): @property def users_service(self) -> Type["src.app.domain.users.services.users_service.DomainUsersService"]: from src.app.domain.users.services.users_service import DomainUsersService + return DomainUsersService diff --git a/src/app/domain/users/services/users_service.py b/src/app/domain/users/services/users_service.py index 8b928f1..9ee689c 100644 --- a/src/app/domain/users/services/users_service.py +++ b/src/app/domain/users/services/users_service.py @@ -4,4 +4,4 @@ class DomainUsersService(AbstractBaseDomainService): """Domain Users service""" - pass \ No newline at end of file + pass diff --git a/src/app/domain/users/value_objects/users_vob.py b/src/app/domain/users/value_objects/users_vob.py index 5a52244..7206eba 100644 --- a/src/app/domain/users/value_objects/users_vob.py +++ b/src/app/domain/users/value_objects/users_vob.py @@ -12,7 +12,7 @@ class EmailPasswordPair: email: str password: str - def __post_init__(self): + def __post_init__(self) -> None: self.__validate_email(value=self.email) self.__validate_password(value=self.password) @@ -27,39 +27,23 @@ def __validate_password(value: str) -> None: {"key": "password", "value": mask_string(value, keep_start=1, keep_end=1)}, ] if len(value) < 8: - raise ValidationError( - message="Must be at least 8 characters long", - details=details - ) + raise ValidationError(message="Must be at least 8 characters long", details=details) # Check for at least one uppercase letter if not re.search(r"[A-Z]", value): - raise ValidationError( - message="Must contain at least one uppercase letter", - details=details - ) + raise ValidationError(message="Must contain at least one uppercase letter", details=details) # Check for at least one lowercase letter if not re.search(r"[a-z]", value): - raise ValidationError( - message="Must contain at least one lowercase letter", - details=details - ) + raise ValidationError(message="Must contain at least one lowercase letter", details=details) # Check for at least one digit if not re.search(r"[0-9]", value): - raise ValidationError( - message="Must contain at least one digit", - details=details - ) + raise ValidationError(message="Must contain at least one digit", details=details) # Check for at least one special character if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", value): - raise ValidationError( - message="Must contain at least one special character", - details=details - ) - + raise ValidationError(message="Must contain at least one special character", details=details) def to_dict(self) -> dict: return { @@ -67,6 +51,7 @@ def to_dict(self) -> dict: "password_hashed": self.password, } + @dataclass(frozen=True) class PhoneNumberCodePair: """Value object representing a pair of phone number and code""" @@ -74,7 +59,7 @@ class PhoneNumberCodePair: phone: str verification_code: str - def __post_init__(self): + def __post_init__(self) -> None: self.__validate_phone(value=self.phone) self.__validate_verification_code(value=self.verification_code) @@ -85,7 +70,7 @@ def __validate_phone(value: str) -> None: if not match or 8 > len(value) or len(value) > 16: raise ValidationError( message="Invalid value", - details=[{"key": "phone", "value": mask_string(value, keep_start=2, keep_end=2)}] + details=[{"key": "phone", "value": mask_string(value, keep_start=2, keep_end=2)}], ) pass diff --git a/src/app/infrastructure/repositories/base/abstract.py b/src/app/infrastructure/repositories/base/abstract.py index 26c40c1..759bc14 100644 --- a/src/app/infrastructure/repositories/base/abstract.py +++ b/src/app/infrastructure/repositories/base/abstract.py @@ -35,25 +35,31 @@ async def is_exists(cls, filter_data: dict) -> bool: @classmethod async def get_first( - cls, filter_data: dict, out_dataclass: Optional[OutRepoGenericType] = None + cls, filter_data: dict, out_dataclass: Optional[Type[OutRepoGenericType]] = None ) -> OutRepoGenericType | None: raise NotImplementedError @classmethod async def get_list( - cls, filter_data: dict, order_data: Tuple[str] = ("id",), out_dataclass: Optional[OutRepoGenericType] = None + cls, + filter_data: dict, + order_data: Tuple[str] = ("id",), + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> List[OutRepoGenericType]: raise NotImplementedError @classmethod async def create( - cls, data: dict, is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + cls, data: dict, is_return_require: bool = False, out_dataclass: Optional[Type[OutRepoGenericType]] = None ) -> OutRepoGenericType | None: raise NotImplementedError @classmethod async def create_bulk( - cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + cls, + items: List[dict], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> List[OutRepoGenericType] | None: raise NotImplementedError @@ -63,13 +69,16 @@ async def update( filter_data: dict, data: Dict[str, Any], is_return_require: bool = False, - out_dataclass: Optional[OutRepoGenericType] = None, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> OutRepoGenericType | None: raise NotImplementedError @classmethod async def update_bulk( - cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + cls, + items: List[dict], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> List[OutRepoGenericType] | None: raise NotImplementedError @@ -79,7 +88,7 @@ async def update_or_create( filter_data: dict, data: Dict[str, Any], is_return_require: bool = False, - out_dataclass: Optional[OutRepoGenericType] = None, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> OutRepoGenericType | None: raise NotImplementedError diff --git a/src/app/infrastructure/repositories/base/base_psql_repository.py b/src/app/infrastructure/repositories/base/base_psql_repository.py index 4d1c670..f6d611b 100644 --- a/src/app/infrastructure/repositories/base/base_psql_repository.py +++ b/src/app/infrastructure/repositories/base/base_psql_repository.py @@ -652,7 +652,7 @@ def _create_dynamic_dataclass(cls) -> Tuple[Callable, List[str]]: @classmethod def out_dataclass_with_columns( - cls, out_dataclass: Optional[OutRepoGenericType] = None + cls, out_dataclass: Optional[Type[OutRepoGenericType]] = None ) -> Tuple[Callable, List[str]]: """Get output dataclass and column names for result conversion""" if not out_dataclass: @@ -697,7 +697,7 @@ async def is_exists(cls, filter_data: dict) -> bool: @classmethod async def get_first( - cls, filter_data: dict, out_dataclass: Optional[OutRepoGenericType] = None + cls, filter_data: dict, out_dataclass: Optional[Type[OutRepoGenericType]] = None ) -> OutRepoGenericType | None: """Get the first record matching the filter criteria""" filter_data_ = filter_data.copy() @@ -720,7 +720,7 @@ async def get_list( cls, filter_data: Optional[dict] = None, order_data: Optional[Tuple[str]] = ("id",), - out_dataclass: Optional[OutRepoGenericType] = None, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> List[OutRepoGenericType]: """Get a list of records matching the filter criteria with pagination and ordering""" if not filter_data: @@ -746,7 +746,7 @@ async def get_list( @classmethod async def create( - cls, data: dict, is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + cls, data: dict, is_return_require: bool = False, out_dataclass: Optional[Type[OutRepoGenericType]] = None ) -> OutRepoGenericType | None: """Create a single record""" data_copy = data.copy() @@ -770,11 +770,13 @@ async def create( # Convert Row to dict using only needed columns (O(1) lookup with set) out_cols_set = set(out_cols) column_names = [col.name for col in model_table.columns.values()] - return out_entity_(**{ - col_name: value - for col_name, value in zip(column_names, raw) - if col_name in out_cols_set - }) + return out_entity_( + **{ + col_name: value + for col_name, value in zip(column_names, raw) + if col_name in out_cols_set + } + ) else: if explicit_id_provided: # For explicit ID, use insert statement to handle potential conflicts better @@ -793,7 +795,7 @@ async def update( filter_data: dict, data: Dict[str, Any], is_return_require: bool = False, - out_dataclass: Optional[OutRepoGenericType] = None, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> OutRepoGenericType | None: """Update records matching the filter criteria""" data_copy = data.copy() @@ -820,7 +822,7 @@ async def update_or_create( filter_data: dict, data: Dict[str, Any], is_return_require: bool = False, - out_dataclass: Optional[OutRepoGenericType] = None, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> OutRepoGenericType | None: """Update existing record or create new one if not found""" is_exists = await cls.is_exists(filter_data=filter_data) @@ -856,7 +858,10 @@ async def remove( @classmethod async def create_bulk( - cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + cls, + items: List[dict], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> List[OutRepoGenericType] | None: """Create multiple records in a single operation""" if not items: @@ -895,7 +900,10 @@ async def create_bulk( @classmethod async def update_bulk( - cls, items: List[dict], is_return_require: bool = False, out_dataclass: Optional[OutRepoGenericType] = None + cls, + items: List[dict], + is_return_require: bool = False, + out_dataclass: Optional[Type[OutRepoGenericType]] = None, ) -> List[OutRepoGenericType] | None: """ Update multiple records in optimized bulk operation. @@ -926,7 +934,7 @@ async def update_bulk( @classmethod async def _bulk_update_with_returning( - cls, session: Any, items: List[dict], out_dataclass: Optional[OutRepoGenericType] = None + cls, session: Any, items: List[dict], out_dataclass: Optional[Type[OutRepoGenericType]] = None ) -> List[OutRepoGenericType]: """Perform bulk update with RETURNING for result collection using ORM""" if not items: diff --git a/src/app/interfaces/api/core/dependencies.py b/src/app/interfaces/api/core/dependencies.py index 61c8d9d..c9b1099 100644 --- a/src/app/interfaces/api/core/dependencies.py +++ b/src/app/interfaces/api/core/dependencies.py @@ -14,4 +14,4 @@ async def validate_api_key(auth_api_key: str = Depends(auth_api_key_schema)) -> async def validate_auth_data(auth_api_key: str = Depends(auth_api_key_schema)) -> dict: auth_api_key_ = str(auth_api_key.credentials).replace("Bearer ", "") # type: ignore decoded = app_svc_container.auth_service.verify_access_token(auth_api_key_) - return decoded.to_dict() \ No newline at end of file + return decoded.to_dict() diff --git a/src/app/interfaces/api/v1/endpoints/auth/resources.py b/src/app/interfaces/api/v1/endpoints/auth/resources.py index 42ab29b..2235b92 100644 --- a/src/app/interfaces/api/v1/endpoints/auth/resources.py +++ b/src/app/interfaces/api/v1/endpoints/auth/resources.py @@ -20,6 +20,7 @@ async def sign_up(data: Annotated[SignUpReq, Body()]) -> dict: email=data.email, password=data.password, ) + assert user is not None return asdict(user) @@ -55,4 +56,3 @@ async def tokens_refreshed(auth_api_key: str = Depends(validate_api_key)) -> dic } return tokens_data - diff --git a/src/app/interfaces/api/v1/endpoints/auth/schemas/req_schemas.py b/src/app/interfaces/api/v1/endpoints/auth/schemas/req_schemas.py index fa72b88..5815ee3 100644 --- a/src/app/interfaces/api/v1/endpoints/auth/schemas/req_schemas.py +++ b/src/app/interfaces/api/v1/endpoints/auth/schemas/req_schemas.py @@ -1,6 +1,3 @@ -import re -from typing import Any -from pydantic import field_validator from src.app.interfaces.api.core.schemas.req_schemas import BaseReq From a64165e46df944b47ecb31af35fec24fd01633dd Mon Sep 17 00:00:00 2001 From: medniy2000 Date: Tue, 25 Nov 2025 17:52:08 +0200 Subject: [PATCH 8/8] fix tests --- tests/application/users/services/test_users_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/application/users/services/test_users_service.py b/tests/application/users/services/test_users_service.py index 7f2baf9..150c5b4 100644 --- a/tests/application/users/services/test_users_service.py +++ b/tests/application/users/services/test_users_service.py @@ -6,7 +6,7 @@ import pytest -from src.app.domain.users.aggregates.common import UserAggregate +from src.app.domain.users.aggregates.user_agg import UserAggregate from src.app.domain.common.utils.common import generate_str from src.app.application.container import container as service_container from tests.fixtures.constants import USERS