diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6912aff --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +config.toml +.venv +.idea +__pycache__ +docker-compose.yml +*.egg-info diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de25917 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.13-alpine + +WORKDIR /app + +RUN pip install uv + +RUN mkdir -p ./src/ + +COPY pyproject.toml /app/pyproject.toml + +RUN uv pip install -e . --system --no-cache + +COPY ./src/template_project /app/src/template_project diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..0f41e0e --- /dev/null +++ b/config.example.toml @@ -0,0 +1,11 @@ +[server] +host = "0.0.0.0" +port = 8080 +access_log = true + +[database] +url = "postgresql+psycopg://username:password@host:post/database" + +[access_token] +crypto_key = "..." +expires_in = 86400 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f0d02d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "template_project" +requires-python = ">=3.13" +description = "template project" +version = "1.0.0" +dependencies = [ + "uuid-utils==0.11.1", + "adaptix==3.0.0b11", + "fastapi==0.119.0", + "uvicorn==0.37.0", + "dishka==1.7.2", + "argon2-cffi==23.1.0", + "cryptography==46.0.3", +] diff --git a/src/template_project/__init__.py b/src/template_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/adapters/__init__.py b/src/template_project/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/adapters/access_token/__init__.py b/src/template_project/adapters/access_token/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/adapters/access_token/cryptographer.py b/src/template_project/adapters/access_token/cryptographer.py new file mode 100644 index 0000000..7639ca3 --- /dev/null +++ b/src/template_project/adapters/access_token/cryptographer.py @@ -0,0 +1,29 @@ +from typing import override +from uuid import UUID + +from cryptography.fernet import Fernet + +from template_project.application.access_token.cryptographer import AccessTokenCryptographer +from template_project.application.access_token.entity import AccessTokenId + + +type RawAccessToken = str + + +class FernetAccessTokenCryptographer(AccessTokenCryptographer): + def __init__(self, fernet: Fernet) -> None: + self._fernet = fernet + + @override + def crypto(self, access_token_id: AccessTokenId) -> RawAccessToken: + return self._fernet.encrypt( + str(access_token_id).encode("utf-8"), + ).decode("utf-8") + + @override + def decrypto(self, raw_access_token: RawAccessToken) -> AccessTokenId: + return AccessTokenId( + UUID( + self._fernet.decrypt(raw_access_token).decode("utf-8"), + ), + ) diff --git a/src/template_project/adapters/access_token/factory.py b/src/template_project/adapters/access_token/factory.py new file mode 100644 index 0000000..6c76de5 --- /dev/null +++ b/src/template_project/adapters/access_token/factory.py @@ -0,0 +1,17 @@ +from typing import override +from template_project.application.access_token.entity import AccessToken +from template_project.application.access_token.entity_factory import AccessTokenFactory +from template_project.application.user.entity import UserId +from template_project.web_api.configuration import AccessTokenConfiguration + + +class DefaultAccessTokenFactory(AccessTokenFactory): + def __init__(self, configuration: AccessTokenConfiguration) -> None: + self._configuration = configuration + + @override + def execute(self, user_id: UserId) -> AccessToken: + return AccessToken.factory( + user_id=user_id, + expires_in=self._configuration.expires_in, + ) diff --git a/src/template_project/adapters/data_gateways/__init__.py b/src/template_project/adapters/data_gateways/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/adapters/data_gateways/access_token.py b/src/template_project/adapters/data_gateways/access_token.py new file mode 100644 index 0000000..c8fd6e4 --- /dev/null +++ b/src/template_project/adapters/data_gateways/access_token.py @@ -0,0 +1,20 @@ +from typing import override + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from template_project.adapters.data_gateways.tables import access_token_table +from template_project.application.access_token.data_gateway import AccessTokenDataGateway +from template_project.application.access_token.entity import AccessToken, AccessTokenId + + +class DefaultAccessTokenDataGateway(AccessTokenDataGateway): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @override + async def load_with_id(self, access_token_id: AccessTokenId) -> AccessToken | None: + statement = select(AccessToken).where( + access_token_table.c.id==access_token_id, + ) + result = await self._session.execute(statement) + return result.scalar_one_or_none() diff --git a/src/template_project/adapters/data_gateways/tables.py b/src/template_project/adapters/data_gateways/tables.py new file mode 100644 index 0000000..bc1b131 --- /dev/null +++ b/src/template_project/adapters/data_gateways/tables.py @@ -0,0 +1,40 @@ +__all__ = ( + "meta_data", + "user_table", + "access_token_table", +) + +from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, MetaData, String, Table +from sqlalchemy.orm import registry + +from template_project.application.access_token.entity import AccessToken +from template_project.application.user.entity import User + + +meta_data = MetaData() + +user_table = Table( + "users", + meta_data, + Column("id", UUID, primary_key=True), + Column("email", String, unique=True, nullable=False), + Column("hashed_password", String, nullable=False), + Column("deleted_at", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False), +) + +access_token_table = Table( + "access_token", + meta_data, + Column("id", UUID, primary_key=True), + Column("user_id", UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + Column("revoked", Boolean, nullable=False), + Column("expires_in", DateTime(timezone=True), nullable=False), + Column("deleted_at", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False), +) + +mapper_registry = registry() + +mapper_registry.map_imperatively(User, user_table) +mapper_registry.map_imperatively(AccessToken, access_token_table) diff --git a/src/template_project/adapters/data_gateways/user.py b/src/template_project/adapters/data_gateways/user.py new file mode 100644 index 0000000..9c1c7bf --- /dev/null +++ b/src/template_project/adapters/data_gateways/user.py @@ -0,0 +1,27 @@ +from typing import override +from sqlalchemy import exists, select +from sqlalchemy.ext.asyncio import AsyncSession + +from template_project.adapters.data_gateways.tables import user_table +from template_project.application.user.data_gateway import UserDataGateway +from template_project.application.user.entity import User, UserId + + +class DefaultUserDataGateway(UserDataGateway): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @override + async def load_with_id(self, id_: UserId) -> User | None: + statement = select(User).where(user_table.c.id==id_) + result = await self._session.execute(statement) + return result.scalar_one_or_none() + + @override + async def exists_by_email(self, email: str) -> bool: + statement = select(exists(select(user_table).where(user_table.c.email == email))) + result = await self._session.execute(statement) + result_fetchone = result.fetchone() + if result_fetchone is None: + return False + return result_fetchone[0] diff --git a/src/template_project/adapters/password_utils.py b/src/template_project/adapters/password_utils.py new file mode 100644 index 0000000..d896541 --- /dev/null +++ b/src/template_project/adapters/password_utils.py @@ -0,0 +1,40 @@ +from abc import abstractmethod +from typing import override + +import argon2 +from argon2.exceptions import Argon2Error + +from template_project.application.common.containers import SecretString +from template_project.application.user.password_utils import PasswordHasher, PasswordVerifying + + +class ArgonPasswordHasher(PasswordHasher): + def __init__( + self, + password_hasher: argon2.PasswordHasher, + ) -> None: + self._password_hasher = password_hasher + + @override + def hash(self, password: SecretString) -> str: + return self._password_hasher.hash(password.get_value()) + + +class ArgonPasswordVerifying(PasswordVerifying): + def __init__( + self, + password_hasher: argon2.PasswordHasher, + ) -> None: + self._password_hasher = password_hasher + + @abstractmethod + def verify( + self, + verifiable_password: SecretString, + hashed_password: str, + ) -> bool: + try: + return self._password_hasher.verify(hashed_password, verifiable_password.get_value()) + except Argon2Error as e: + return False + diff --git a/src/template_project/adapters/unit_of_work.py b/src/template_project/adapters/unit_of_work.py new file mode 100644 index 0000000..f3b94ed --- /dev/null +++ b/src/template_project/adapters/unit_of_work.py @@ -0,0 +1,18 @@ +from typing import Any, override + +from sqlalchemy.ext.asyncio import AsyncSession + +from template_project.application.common.unit_of_work import UnitOfWork + + +class DefaultUnitOfWork(UnitOfWork): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @override + def add(self, *entities: Any) -> None: + self._session.add_all(entities) + + @override + async def commit(self) -> None: + await self._session.commit() diff --git a/src/template_project/application/__init__.py b/src/template_project/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/access_token/cryptographer.py b/src/template_project/application/access_token/cryptographer.py new file mode 100644 index 0000000..41c5ba3 --- /dev/null +++ b/src/template_project/application/access_token/cryptographer.py @@ -0,0 +1,17 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.access_token.entity import AccessTokenId + + +type RawAccessToken = str + + +class AccessTokenCryptographer(Protocol): + @abstractmethod + def crypto(self, access_token_id: AccessTokenId) -> RawAccessToken: + raise NotImplementedError + + @abstractmethod + def decrypto(self, raw_access_token: RawAccessToken) -> AccessTokenId: + raise NotImplementedError diff --git a/src/template_project/application/access_token/data_gateway.py b/src/template_project/application/access_token/data_gateway.py new file mode 100644 index 0000000..5c7bfad --- /dev/null +++ b/src/template_project/application/access_token/data_gateway.py @@ -0,0 +1,10 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.access_token.entity import AccessToken, AccessTokenId + + +class AccessTokenDataGateway(Protocol): + @abstractmethod + async def load_with_id(self, access_token_id: AccessTokenId) -> AccessToken | None: + raise NotImplementedError diff --git a/src/template_project/application/access_token/entity.py b/src/template_project/application/access_token/entity.py new file mode 100644 index 0000000..fdc0f2b --- /dev/null +++ b/src/template_project/application/access_token/entity.py @@ -0,0 +1,49 @@ +from datetime import UTC, datetime, timedelta +from typing import NewType, Self +from uuid import UUID + +from uuid_utils.compat import uuid7 + +from template_project.application.access_token.errors import AccessTokenExpiredError +from template_project.application.common.entity import Entity, to_entity +from template_project.application.user.entity import UserId + +AccessTokenId = NewType("AccessTokenId", UUID) + +@to_entity +class AccessToken(Entity[AccessTokenId]): + user_id: UserId + revoked: bool + expires_in: datetime + + @classmethod + def factory( + cls, + user_id: UserId, + expires_in: timedelta, + ) -> Self: + current_date_time = datetime.now(tz=UTC) + + return cls( + id=AccessTokenId(uuid7()), + created_at=current_date_time, + user_id=user_id, + expires_in=current_date_time + expires_in, + revoked=False, + ) + + + def ensure_expired(self) -> None: + if self.expired_predicate(): + raise AccessTokenExpiredError(id_=self.id) + + def expired_predicate(self) -> bool: + return ( + (self.expires_in < datetime.now(tz=UTC)) + or self.revoked + or self.deleted_at is not None + ) + + def revoke(self) -> None: + self.revoked = True + self.deleted_at = datetime.now(tz=UTC) diff --git a/src/template_project/application/access_token/entity_factory.py b/src/template_project/application/access_token/entity_factory.py new file mode 100644 index 0000000..c96ca0f --- /dev/null +++ b/src/template_project/application/access_token/entity_factory.py @@ -0,0 +1,11 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.access_token.entity import AccessToken +from template_project.application.user.entity import UserId + + +class AccessTokenFactory(Protocol): + @abstractmethod + def execute(self, user_id: UserId) -> AccessToken: + raise NotImplementedError diff --git a/src/template_project/application/access_token/errors.py b/src/template_project/application/access_token/errors.py new file mode 100644 index 0000000..4267a13 --- /dev/null +++ b/src/template_project/application/access_token/errors.py @@ -0,0 +1,13 @@ +from typing import override + +from template_project.application.access_token.entity import AccessTokenId +from template_project.application.common.errors import ApplicationError, to_error + + +@to_error +class AccessTokenExpiredError(ApplicationError): + id_: AccessTokenId + + @override + def __str__(self) -> str: + return f"Access token id={self.id_!r} expried" diff --git a/src/template_project/application/common/__init__.py b/src/template_project/application/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/common/containers.py b/src/template_project/application/common/containers.py new file mode 100644 index 0000000..2eabd81 --- /dev/null +++ b/src/template_project/application/common/containers.py @@ -0,0 +1,42 @@ +from collections.abc import Container, Hashable, Sized +from typing import Any, Final, override + +_SECRET_VALUE: Final = "********" # noqa: S105 + + +class SecretString(Container[bool], Hashable, Sized): + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + self._value = value + + @override + def __hash__(self) -> int: + return hash(self._value) + + @override + def __len__(self) -> int: + return len(self._value) + + @override + def __eq__(self, value: object) -> bool: + if isinstance(value, str): + return self._value == value + return NotImplemented + + @override + def __contains__(self, value: object) -> Any: + if isinstance(value, str): + return value in self._value + return NotImplemented + + @override + def __str__(self) -> str: + return _SECRET_VALUE + + @override + def __repr__(self) -> str: + return f"<{self.__class__.__name__}(value={_SECRET_VALUE!r})>" + + def get_value(self) -> str: + return self._value diff --git a/src/template_project/application/common/data_structure.py b/src/template_project/application/common/data_structure.py new file mode 100644 index 0000000..4fd1c8a --- /dev/null +++ b/src/template_project/application/common/data_structure.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import dataclass_transform + + +@dataclass_transform( + frozen_default=True, + eq_default=False, + kw_only_default=True, +) +def to_data_structure[_InteractorClsT](interactor_cls: type[_InteractorClsT]) -> type[_InteractorClsT]: + return dataclass( + kw_only=True, + eq=False, + match_args=False, + frozen=True, + slots=True, + )(interactor_cls) \ No newline at end of file diff --git a/src/template_project/application/common/entity.py b/src/template_project/application/common/entity.py new file mode 100644 index 0000000..03a39d0 --- /dev/null +++ b/src/template_project/application/common/entity.py @@ -0,0 +1,33 @@ +from collections.abc import Hashable +from dataclasses import dataclass +from datetime import datetime +from typing import dataclass_transform, override +from uuid import UUID + +from template_project.application.common.errors import EntityAlreadyDeletedError + + +@dataclass_transform(kw_only_default=True) +def to_entity[_EntityCLsT](entity_cls: type[_EntityCLsT]) -> type[_EntityCLsT]: + return dataclass(kw_only=True)(entity_cls) + + +@to_entity +class Entity[_EntityId: UUID](Hashable): + id: _EntityId + created_at: datetime + deleted_at: datetime | None = None + + def ensure_not_deleted(self) -> None: + if self.deleted_at is not None: + raise EntityAlreadyDeletedError(entity_name=self.__class__.__name__) + + @override + def __eq__(self, other: object) -> bool: + if isinstance(other, Entity): + return self.id == other.id + return NotImplemented + + @override + def __hash__(self) -> int: + return hash(self.id) diff --git a/src/template_project/application/common/errors.py b/src/template_project/application/common/errors.py new file mode 100644 index 0000000..3bf5e2a --- /dev/null +++ b/src/template_project/application/common/errors.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import dataclass_transform, override + + +@dataclass_transform( + kw_only_default=True, + eq_default=False, +) +def to_error[T](cls: type[T]) -> type[T]: + return dataclass( + kw_only=True, + eq=False, + repr=False, + match_args=False, + )(cls) + + +@to_error +class ApplicationError(Exception): + pass + + +@to_error +class EntityAlreadyDeletedError(ApplicationError): + entity_name: str + + @override + def __str__(self) -> str: + return f"Entity {self.entity_name!r} already deleted" diff --git a/src/template_project/application/common/identity_provider.py b/src/template_project/application/common/identity_provider.py new file mode 100644 index 0000000..fd506a9 --- /dev/null +++ b/src/template_project/application/common/identity_provider.py @@ -0,0 +1,10 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.user.entity import User + + +class IdentityProvider(Protocol): + @abstractmethod + async def get_current_user(self) -> User: + raise NotImplementedError diff --git a/src/template_project/application/common/interactor.py b/src/template_project/application/common/interactor.py new file mode 100644 index 0000000..79a0407 --- /dev/null +++ b/src/template_project/application/common/interactor.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import dataclass_transform + + +@dataclass_transform( + frozen_default=True, + eq_default=False, + kw_only_default=True, +) +def to_interactor[_InteractorClsT](interactor_cls: type[_InteractorClsT]) -> type[_InteractorClsT]: + return dataclass( + kw_only=True, + eq=False, + repr=False, + frozen=True, + match_args=False, + slots=True, + )(interactor_cls) \ No newline at end of file diff --git a/src/template_project/application/common/unit_of_work.py b/src/template_project/application/common/unit_of_work.py new file mode 100644 index 0000000..ab6bdfe --- /dev/null +++ b/src/template_project/application/common/unit_of_work.py @@ -0,0 +1,12 @@ +from abc import abstractmethod +from typing import Any, Protocol + + +class UnitOfWork(Protocol): + @abstractmethod + def add(self, *entities: Any) -> None: + raise NotImplementedError + + @abstractmethod + async def commit(self) -> None: + raise NotImplementedError diff --git a/src/template_project/application/user/__init__.py b/src/template_project/application/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/user/data_gateway.py b/src/template_project/application/user/data_gateway.py new file mode 100644 index 0000000..bce7366 --- /dev/null +++ b/src/template_project/application/user/data_gateway.py @@ -0,0 +1,17 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.user.entity import User, UserId + + +class UserDataGateway(Protocol): + @abstractmethod + async def load_with_id(self, id_: UserId) -> User | None: + raise NotImplementedError + + @abstractmethod + async def exists_by_email( + self, + email: str + ) -> bool: + raise NotImplementedError \ No newline at end of file diff --git a/src/template_project/application/user/entity.py b/src/template_project/application/user/entity.py new file mode 100644 index 0000000..bc1dd22 --- /dev/null +++ b/src/template_project/application/user/entity.py @@ -0,0 +1,27 @@ +from datetime import UTC, datetime +from typing import NewType, Self +from uuid import UUID + +from uuid_utils.compat import uuid7 + +from template_project.application.common.entity import Entity, to_entity + +UserId = NewType("UserId", UUID) + +@to_entity +class User(Entity[UserId]): + email: str + hashed_password: str + + @classmethod + def factory( + cls, + email: str, + hashed_password: str, + ) -> Self: + return cls( + id=UserId(uuid7()), + email=email, + hashed_password=hashed_password, + created_at=datetime.now(tz=UTC), + ) diff --git a/src/template_project/application/user/errors.py b/src/template_project/application/user/errors.py new file mode 100644 index 0000000..796bd6f --- /dev/null +++ b/src/template_project/application/user/errors.py @@ -0,0 +1,15 @@ +from typing import override +from template_project.application.common.errors import ApplicationError, to_error + + +@to_error +class UserWithEmailAlreadyExistsError(ApplicationError): + email: str + + @override + def __str__(self) -> str: + return f"User with the email={self.email!r} already exists" + +@to_error +class UserUnauthorizedError(ApplicationError): + pass diff --git a/src/template_project/application/user/interactors/__init__.py b/src/template_project/application/user/interactors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/user/interactors/get_me.py b/src/template_project/application/user/interactors/get_me.py new file mode 100644 index 0000000..7bf7327 --- /dev/null +++ b/src/template_project/application/user/interactors/get_me.py @@ -0,0 +1,23 @@ +from adaptix.conversion import get_converter + +from template_project.application.common.data_structure import to_data_structure +from template_project.application.common.identity_provider import IdentityProvider +from template_project.application.common.interactor import to_interactor +from template_project.application.user.entity import User, UserId + + +@to_data_structure +class GetMeResponse: + id: UserId + email: str + +response_converter = get_converter(User, GetMeResponse) + + +@to_interactor +class GetMeInteractor: + identity_provider: IdentityProvider + + async def execute(self) -> GetMeResponse: + current_user = await self.identity_provider.get_current_user() + return response_converter(current_user) diff --git a/src/template_project/application/user/interactors/sign_up.py b/src/template_project/application/user/interactors/sign_up.py new file mode 100644 index 0000000..ed59799 --- /dev/null +++ b/src/template_project/application/user/interactors/sign_up.py @@ -0,0 +1,49 @@ +from template_project.application.access_token.cryptographer import AccessTokenCryptographer +from template_project.application.access_token.entity_factory import AccessTokenFactory +from template_project.application.common.containers import SecretString +from template_project.application.common.data_structure import to_data_structure +from template_project.application.common.interactor import to_interactor +from template_project.application.common.unit_of_work import UnitOfWork +from template_project.application.user.data_gateway import UserDataGateway +from template_project.application.user.entity import User +from template_project.application.user.errors import UserWithEmailAlreadyExistsError +from template_project.application.user.password_utils import PasswordHasher + + +@to_data_structure +class UserSignUpResponse: + access_token: str + + +@to_interactor +class UserSignUpInteractor: + unit_of_work: UnitOfWork + password_hasher: PasswordHasher + user_data_gateway: UserDataGateway + access_token_factory: AccessTokenFactory + access_token_cryptographer: AccessTokenCryptographer + + async def execute( + self, + email: str, + password: SecretString, + ) -> UserSignUpResponse: + exists_by_email = await self.user_data_gateway.exists_by_email(email) + if exists_by_email: + raise UserWithEmailAlreadyExistsError(email=email) + + hashed_password = self.password_hasher.hash(password) + + user = User.factory( + email=email, + hashed_password=hashed_password, + ) + access_token = self.access_token_factory.execute(user.id) + crypted_access_token = self.access_token_cryptographer.crypto(access_token) + + response = UserSignUpResponse(access_token=crypted_access_token) + + self.unit_of_work.add(user, access_token) + await self.unit_of_work.commit() + + return response diff --git a/src/template_project/application/user/password_utils.py b/src/template_project/application/user/password_utils.py new file mode 100644 index 0000000..78b72d4 --- /dev/null +++ b/src/template_project/application/user/password_utils.py @@ -0,0 +1,20 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.common.containers import SecretString + + +class PasswordHasher(Protocol): + @abstractmethod + def hash(self, password: SecretString) -> str: + raise NotImplementedError + + +class PasswordVerifying(Protocol): + @abstractmethod + def verify( + self, + verifiable_password: SecretString, + hashed_password: str, + ) -> bool: + raise NotImplementedError diff --git a/src/template_project/web_api/__init__.py b/src/template_project/web_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/web_api/configuration.py b/src/template_project/web_api/configuration.py new file mode 100644 index 0000000..6e7e087 --- /dev/null +++ b/src/template_project/web_api/configuration.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from datetime import timedelta +from pathlib import Path +from tomllib import loads +from typing import dataclass_transform +from adaptix import P, Retort, loader + +from template_project.application.common.containers import SecretString + + +@dataclass_transform(frozen_default=True) +def to_configuration[ClsT](cls: type[ClsT]) -> type[ClsT]: + return dataclass(frozen=True, slots=True, repr=False)(cls) + + +@to_configuration +class DatabaseConfiguration: + url: SecretString + + +@to_configuration +class AccessTokenConfiguration: + crypto_key: str + expires_in: timedelta + + +@to_configuration +class ServerConfiguration: + host: str + port: int + access_log: bool + + +@to_configuration +class Configuration: + server: ServerConfiguration + database: DatabaseConfiguration + access_token: AccessTokenConfiguration + + +retort = Retort( + recipe=[ + loader(SecretString, SecretString), + loader(P[AccessTokenConfiguration].expires_in, lambda value: timedelta(seconds=value)), + ], +) + + +def load_configuration(path: Path) -> Configuration: + with path.open("r", encoding="utf-8") as config: + data = loads(config.read()) + + return retort.load(data, Configuration) diff --git a/src/template_project/web_api/entiry_point.py b/src/template_project/web_api/entiry_point.py new file mode 100644 index 0000000..b7e7514 --- /dev/null +++ b/src/template_project/web_api/entiry_point.py @@ -0,0 +1,99 @@ +import argparse +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +import sys +from typing import Final + +from dishka import AsyncContainer +from dishka.integrations.fastapi import setup_dishka +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import uvicorn + +from template_project.web_api.configuration import load_configuration +from template_project.web_api.ioc.make import make_ioc + +LOG_CONFIG: Final = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "%(asctime)s [%(levelname)s] [%(name)s] %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + }, + }, + "root": { + "level": "DEBUG", + "handlers": ["console"], + }, +} + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + yield + await app.state.dishka_container.close() + +def make_asgi_application( + ioc: AsyncContainer, +) -> FastAPI: + app = FastAPI( + lifespan=lifespan, + docs_url="/docs", + title="Template project", + description="Template project API", + version="1.0.0", + openapi_url="/openapi.json", + ) + origins = ["*"] + + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + setup_dishka(container=ioc, app=app) + + return app + +def _main( + configuration_path: Path, +) -> None: + configuration = load_configuration(configuration_path) + ioc = make_ioc(configuration) + asgi_application = make_asgi_application(ioc) + + uvicorn.run( + asgi_application, + port=configuration.server.port, + host=configuration.server.host, + log_config=LOG_CONFIG, + access_log=configuration.server.access_log, + ) + + +def main() -> None: + if sys.platform == "win32": + from asyncio import WindowsSelectorEventLoopPolicy + asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy()) + + arg_parser = argparse.ArgumentParser() + subparsers = arg_parser.add_subparsers() + + web_api_parser = subparsers.add_parser("web_api") + web_api_parser.add_argument("configuration", dest="configuration", type=Path) + + args = arg_parser.parse_args() + _main(args.configuration) + +if __name__ == "__main__": + main() diff --git a/src/template_project/web_api/identity_provider.py b/src/template_project/web_api/identity_provider.py new file mode 100644 index 0000000..93bbaa9 --- /dev/null +++ b/src/template_project/web_api/identity_provider.py @@ -0,0 +1,73 @@ +from abc import abstractmethod + +from fastapi import Request + +from template_project.application.access_token.cryptographer import AccessTokenCryptographer +from template_project.application.access_token.data_gateway import AccessTokenDataGateway +from template_project.application.access_token.entity import AccessTokenId +from template_project.application.access_token.errors import AccessTokenExpiredError +from template_project.application.common.identity_provider import IdentityProvider +from template_project.application.user.data_gateway import UserDataGateway +from template_project.application.user.entity import User +from template_project.application.user.errors import UserUnauthorizedError + + +TOKEN_TYPE = "Bearer" +BEARER_SECTIONS = 2 +AUTH_HEADER = "Authorization" + + +class WebApiIdentityProvider(IdentityProvider): + def __init__( + self, + request: Request, + user_data_gateway: UserDataGateway, + access_token_data_gateway: AccessTokenDataGateway, + access_token_cryptographer: AccessTokenCryptographer, + ) -> None: + self._request = request + + self._user_data_gateway = user_data_gateway + self._access_token_data_gateway = access_token_data_gateway + self._access_token_cryptographer = access_token_cryptographer + + @abstractmethod + async def get_current_user(self) -> User: + auth_tokn = self._request.headers[AUTH_HEADER] + + access_token_id = self._parse_token(auth_tokn) + if access_token_id is None: + raise UserUnauthorizedError + + access_token = await self._access_token_data_gateway.load_with_id(access_token_id) + if access_token is None: + raise UserUnauthorizedError + + try: + access_token.ensure_expired() + except AccessTokenExpiredError as error: + raise UserUnauthorizedError from error + + user = await self._user_data_gateway.load_with_id(access_token.user_id) + + if user is None: + raise UserUnauthorizedError + + return user + + def _parse_token(self, token: str) -> AccessTokenId | None: + authorization_header = self._request.headers.get(AUTH_HEADER) + + if authorization_header is None: + return None + + sections = authorization_header.split(" ") + if len(sections) != BEARER_SECTIONS: + return None + + token_type, token = sections + + if token_type != TOKEN_TYPE: + return None + + return self._access_token_cryptographer.decrypto(token) diff --git a/src/template_project/web_api/ioc/__init__.py b/src/template_project/web_api/ioc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/web_api/ioc/connection.py b/src/template_project/web_api/ioc/connection.py new file mode 100644 index 0000000..33d155a --- /dev/null +++ b/src/template_project/web_api/ioc/connection.py @@ -0,0 +1,22 @@ +from typing import AsyncIterable +from dishka import Provider, Scope, provide +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine + +from template_project.web_api.configuration import DatabaseConfiguration + + +class ConnectionProvider(Provider): + @provide(scope=Scope.APP) + async def make_engine(self, configuration: DatabaseConfiguration) -> AsyncIterable[AsyncEngine]: + engine = create_async_engine(configuration.url.get_value()) + yield engine + await engine.dispose() + + @provide() + async def make_connection(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]: + session = AsyncSession( + bind=engine, + expire_on_commit=True, + ) + async with session: + yield session diff --git a/src/template_project/web_api/ioc/cryptographer.py b/src/template_project/web_api/ioc/cryptographer.py new file mode 100644 index 0000000..46451dc --- /dev/null +++ b/src/template_project/web_api/ioc/cryptographer.py @@ -0,0 +1,25 @@ +import argon2 +from cryptography.fernet import Fernet +from dishka import Provider, Scope, WithParents, provide, provide_all + +from template_project.adapters.access_token.cryptographer import FernetAccessTokenCryptographer +from template_project.adapters.password_utils import ArgonPasswordHasher, ArgonPasswordVerifying +from template_project.web_api.configuration import AccessTokenConfiguration + + +class CryptographerProvider(Provider): + scope = Scope.APP + + @provide + def argon_password_hasher(self) -> argon2.PasswordHasher: + return argon2.PasswordHasher() + + @provide + def fernet(self, configuration: AccessTokenConfiguration) -> Fernet: + return Fernet(configuration.crypto_key) + + access_token_cryptographer = provide(WithParents[FernetAccessTokenCryptographer]) + password_utils = provide_all( + WithParents[ArgonPasswordHasher], + WithParents[ArgonPasswordVerifying], + ) diff --git a/src/template_project/web_api/ioc/data_gateway.py b/src/template_project/web_api/ioc/data_gateway.py new file mode 100644 index 0000000..6e45e09 --- /dev/null +++ b/src/template_project/web_api/ioc/data_gateway.py @@ -0,0 +1,15 @@ +from dishka import Provider, Scope, WithParents, provide, provide_all + +from template_project.adapters.data_gateways.access_token import DefaultAccessTokenDataGateway +from template_project.adapters.data_gateways.user import DefaultUserDataGateway +from template_project.adapters.unit_of_work import DefaultUnitOfWork + + +class DataGatewayProvider(Provider): + scope = Scope.REQUEST + + unit_of_work = provide(WithParents[DefaultUnitOfWork]) + data_gateways = provide_all( + WithParents[DefaultUserDataGateway], + WithParents[DefaultAccessTokenDataGateway], + ) diff --git a/src/template_project/web_api/ioc/factory.py b/src/template_project/web_api/ioc/factory.py new file mode 100644 index 0000000..8a7cfc2 --- /dev/null +++ b/src/template_project/web_api/ioc/factory.py @@ -0,0 +1,11 @@ +from dishka import Provider, Scope, provide_all + +from template_project.adapters.access_token.factory import DefaultAccessTokenFactory + + +class FactoryProvider(Provider): + scope = Scope.APP + + factories = provide_all( + DefaultAccessTokenFactory, + ) diff --git a/src/template_project/web_api/ioc/interactor.py b/src/template_project/web_api/ioc/interactor.py new file mode 100644 index 0000000..657f2c2 --- /dev/null +++ b/src/template_project/web_api/ioc/interactor.py @@ -0,0 +1,11 @@ +from dishka import Provider, Scope, provide_all + +from template_project.application.user.interactors.sign_up import UserSignUpInteractor + + +class InteractorProvider(Provider): + scope = Scope.REQUEST + + interactors = provide_all( + UserSignUpInteractor, + ) diff --git a/src/template_project/web_api/ioc/make.py b/src/template_project/web_api/ioc/make.py new file mode 100644 index 0000000..4443ad2 --- /dev/null +++ b/src/template_project/web_api/ioc/make.py @@ -0,0 +1,23 @@ +from dishka import AsyncContainer, make_async_container +from dishka.integrations.fastapi import FastapiProvider + +from template_project.web_api.configuration import AccessTokenConfiguration, Configuration, DatabaseConfiguration, ServerConfiguration +from template_project.web_api.ioc.data_gateway import DataGatewayProvider +from template_project.web_api.ioc.factory import FactoryProvider +from template_project.web_api.ioc.interactor import InteractorProvider +from template_project.web_api.ioc.cryptographer import CryptographerProvider + + +def make_ioc(configuration: Configuration) -> AsyncContainer: + return make_async_container( + FactoryProvider(), + FastapiProvider(), + InteractorProvider(), + DataGatewayProvider(), + CryptographerProvider(), + context={ + ServerConfiguration: configuration.server, + DatabaseConfiguration: configuration.database, + AccessTokenConfiguration: configuration.access_token, + }, + ) diff --git a/src/template_project/web_api/routes/__init__.py b/src/template_project/web_api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/web_api/routes/user.py b/src/template_project/web_api/routes/user.py new file mode 100644 index 0000000..497b614 --- /dev/null +++ b/src/template_project/web_api/routes/user.py @@ -0,0 +1,33 @@ +from dishka import FromDishka +from dishka.integrations.fastapi import DishkaRoute +from fastapi import APIRouter +from pydantic import BaseModel, SecretStr + +from template_project.application.common.containers import SecretString +from template_project.application.user.interactors.sign_up import UserSignUpInteractor + + +router = APIRouter(route_class=DishkaRoute) + + +class UserSignUpRequest(BaseModel): + email: str + password: SecretStr + + +class UserSignUpResponse(BaseModel): + access_token: str + + +@router.post("/user/sign_up") +async def sign_up( + request: UserSignUpRequest, + interactor: FromDishka[UserSignUpInteractor], +) -> UserSignUpResponse: + response_interactor = await interactor.execute( + email=request.email, + password=SecretString(request.password.get_secret_value()) + ) + return UserSignUpResponse( + access_token=response_interactor.access_token, + )