You've already forked RekomenciBackend
fast init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
config.toml
|
||||
.venv
|
||||
.idea
|
||||
__pycache__
|
||||
docker-compose.yml
|
||||
*.egg-info
|
||||
+13
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user