diff --git a/api/app_builder/dependencies.py b/api/app_builder/dependencies.py new file mode 100644 index 0000000..73050b5 --- /dev/null +++ b/api/app_builder/dependencies.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from api.application.abstractions.uow import UnitOfWork +from api.application.usecase.create_user import CreateUser +from api.domain.user.repository import UserRepository +from api.infrastructure.dependencies.adapters import ( + create_engine, + create_session_maker, + new_session, + new_unit_of_work, +) +from api.infrastructure.dependencies.repositories import get_user_repository +from api.infrastructure.dependencies.usecases import provide_create_user + + +def init_dependencies(app: FastAPI) -> None: + app.dependency_overrides[AsyncEngine] = create_engine + app.dependency_overrides[async_sessionmaker[AsyncSession]] = create_session_maker + app.dependency_overrides[AsyncSession] = new_session + app.dependency_overrides[UserRepository] = get_user_repository + app.dependency_overrides[UnitOfWork] = new_unit_of_work + app.dependency_overrides[CreateUser] = provide_create_user diff --git a/api/app_builder/main.py b/api/app_builder/main.py index 17db72d..444fb99 100644 --- a/api/app_builder/main.py +++ b/api/app_builder/main.py @@ -1,9 +1,13 @@ from fastapi import FastAPI +from api.app_builder.dependencies import init_dependencies + from .routers import init_routers def app_factory() -> FastAPI: app = FastAPI() + init_dependencies(app) init_routers(app) + return app diff --git a/api/application/abstractions/__init__.py b/api/application/abstractions/__init__.py new file mode 100644 index 0000000..6325ed2 --- /dev/null +++ b/api/application/abstractions/__init__.py @@ -0,0 +1,3 @@ +from .uow import UnitOfWork + +__all__ = ("UnitOfWork",) diff --git a/api/application/abstractions/uow.py b/api/application/abstractions/uow.py new file mode 100644 index 0000000..3d538c5 --- /dev/null +++ b/api/application/abstractions/uow.py @@ -0,0 +1,9 @@ +from typing import Protocol + + +class UnitOfWork(Protocol): + async def commit(self) -> None: + raise NotImplementedError + + async def rollback(self) -> None: + raise NotImplementedError diff --git a/api/application/contracts/user/__init__.py b/api/application/contracts/user/__init__.py index 27ba57a..670ebd9 100644 --- a/api/application/contracts/user/__init__.py +++ b/api/application/contracts/user/__init__.py @@ -1,3 +1,4 @@ -from .user_response import UserResponse +from .user_request import UserCreateRequest +from .user_response import UserDetaledResponse, UserResponse -__all__ = ("UserResponse",) +__all__ = ("UserResponse", "UserDetaledResponse", "UserCreateRequest") diff --git a/api/application/contracts/user/user_request.py b/api/application/contracts/user/user_request.py new file mode 100644 index 0000000..aeae1b7 --- /dev/null +++ b/api/application/contracts/user/user_request.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UserCreateRequest: + name: str + email: str + password: str diff --git a/api/application/contracts/user/user_response.py b/api/application/contracts/user/user_response.py index 110a868..66b0f1a 100644 --- a/api/application/contracts/user/user_response.py +++ b/api/application/contracts/user/user_response.py @@ -1,7 +1,16 @@ from dataclasses import dataclass +from uuid import UUID @dataclass(frozen=True) class UserResponse: name: str email: str + + +@dataclass(frozen=True) +class UserDetaledResponse: + id: UUID + name: str + email: str + hashed_password: str diff --git a/api/application/usecase/__init__.py b/api/application/usecase/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/application/usecase/create_user.py b/api/application/usecase/create_user.py new file mode 100644 index 0000000..96b2de7 --- /dev/null +++ b/api/application/usecase/create_user.py @@ -0,0 +1,15 @@ +from api.application.abstractions import UnitOfWork +from api.application.contracts.user.user_request import UserCreateRequest +from api.domain.user.model import User +from api.domain.user.repository import UserRepository + + +class CreateUser: + def __init__(self, uow: UnitOfWork, user_repository: UserRepository) -> None: + self.uow = uow + self.user_repository = user_repository + + async def execute(self, request: UserCreateRequest) -> None: + user = User.create(name=request.name, email=request.email, password=request.password) + await self.user_repository.create_user(user=user) + await self.uow.commit() diff --git a/api/domain/__init__.py b/api/domain/__init__.py new file mode 100644 index 0000000..46d4846 --- /dev/null +++ b/api/domain/__init__.py @@ -0,0 +1,3 @@ +from .error import DomainError + +__all__ = ("DomainError",) diff --git a/api/domain/error.py b/api/domain/error.py new file mode 100644 index 0000000..5aba692 --- /dev/null +++ b/api/domain/error.py @@ -0,0 +1,4 @@ +class DomainError(Exception): + def __init__(self, message: str, *args: object) -> None: + super().__init__(*args) + self.message = message diff --git a/api/domain/user/__init__.py b/api/domain/user/__init__.py new file mode 100644 index 0000000..6ffcb0a --- /dev/null +++ b/api/domain/user/__init__.py @@ -0,0 +1,5 @@ +from .error import UserNotFoundError, UserValidationError +from .model import User +from .repository import UserRepository + +__all__ = ("UserValidationError", "UserNotFoundError", "User", "UserRepository") diff --git a/api/domain/user/error.py b/api/domain/user/error.py new file mode 100644 index 0000000..5da4e25 --- /dev/null +++ b/api/domain/user/error.py @@ -0,0 +1,9 @@ +from api.domain import DomainError + + +class UserNotFoundError(DomainError): + ... + + +class UserValidationError(DomainError): + ... diff --git a/api/domain/user/model.py b/api/domain/user/model.py new file mode 100644 index 0000000..018f663 --- /dev/null +++ b/api/domain/user/model.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from uuid import UUID, uuid4 + +from api.domain.user import UserValidationError + + +@dataclass +class User: + id: UUID + name: str + email: str + password: str + + @staticmethod + def create(name: str, email: str, password: str) -> "User": + if not name: + raise UserValidationError("User name cannot be empty") + + if not email: + raise UserValidationError("User email cannot be empty") + + if len(name) > 50: + raise UserValidationError("User name cannot be longer than 50 characters") + + if len(email) > 30: + raise UserValidationError("User email cannot be longer than 30 characters") + + return User( + id=uuid4(), + name=name, + email=email, + password=password, + ) diff --git a/api/domain/user/repository.py b/api/domain/user/repository.py new file mode 100644 index 0000000..9da3d0e --- /dev/null +++ b/api/domain/user/repository.py @@ -0,0 +1,14 @@ +from typing import Protocol + +from api.domain.user.model import User + + +class UserRepository(Protocol): + async def get_user(self, filter: dict) -> User | None: + raise NotImplementedError + + async def create_user(self, user: User) -> None: + raise NotImplementedError + + async def get_users(self) -> list[User] | None: + raise NotImplementedError diff --git a/api/infrastructure/__init__.py b/api/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/infrastructure/dependencies/__init__.py b/api/infrastructure/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/infrastructure/dependencies/adapters.py b/api/infrastructure/dependencies/adapters.py new file mode 100644 index 0000000..f28bd29 --- /dev/null +++ b/api/infrastructure/dependencies/adapters.py @@ -0,0 +1,42 @@ +from collections.abc import AsyncIterable +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from api.application.abstractions import UnitOfWork +from api.infrastructure.dependencies.stub import Stub +from api.infrastructure.persistence.uow import SqlAlchemyUnitOfWork + + +def new_unit_of_work( + session: Annotated[AsyncSession, Depends(Stub(AsyncSession))], +) -> UnitOfWork: + return SqlAlchemyUnitOfWork(session) + + +def create_engine() -> AsyncEngine: + return create_async_engine("postgresql+asyncpg://postgresql+asyncpg//demo_user:user_pass@db:5432/serviceman_db") + + +def create_session_maker( + engine: Annotated[AsyncEngine, Depends(Stub(AsyncEngine))], +) -> async_sessionmaker[AsyncSession]: + maker = async_sessionmaker(engine, expire_on_commit=False) + print("session_maker id:", id(maker)) + return maker + + +async def new_session( + session_maker: Annotated[ + async_sessionmaker[AsyncSession], + Depends(Stub(async_sessionmaker[AsyncSession])), + ], +) -> AsyncIterable[AsyncSession]: + async with session_maker() as session: + yield session diff --git a/api/infrastructure/dependencies/repositories.py b/api/infrastructure/dependencies/repositories.py new file mode 100644 index 0000000..6a97c66 --- /dev/null +++ b/api/infrastructure/dependencies/repositories.py @@ -0,0 +1,17 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from api.domain.user import UserRepository +from api.infrastructure.persistence.repositories.user_repository import ( + SqlAlchemyUserRepository, +) + +from .stub import Stub + + +def get_user_repository( + session: Annotated[AsyncSession, Depends(Stub(AsyncSession))], +) -> UserRepository: + return SqlAlchemyUserRepository(session) diff --git a/api/infrastructure/dependencies/stub.py b/api/infrastructure/dependencies/stub.py new file mode 100644 index 0000000..8af7a5d --- /dev/null +++ b/api/infrastructure/dependencies/stub.py @@ -0,0 +1,41 @@ +from collections.abc import Callable + + +class Stub: + """ + This class is used to prevent fastapi from digging into + real dependencies attributes detecting them as request data + + So instead of + `interactor: Annotated[Interactor, Depends()]` + Write + `interactor: Annotated[Interactor, Depends(Stub(Interactor))]` + + And then you can declare how to create it: + `app.dependency_overrids[Interactor] = some_real_factory` + + """ + + def __init__(self, dependency: Callable, **kwargs): + self._dependency = dependency + self._kwargs = kwargs + + def __call__(self): + raise NotImplementedError + + def __eq__(self, other) -> bool: + if isinstance(other, Stub): + return self._dependency == other._dependency and self._kwargs == other._kwargs + else: + if not self._kwargs: + return self._dependency == other + return False + + def __hash__(self): + if not self._kwargs: + return hash(self._dependency) + serial = ( + self._dependency, + *self._kwargs.items(), + ) + return hash(serial) diff --git a/api/infrastructure/dependencies/usecases.py b/api/infrastructure/dependencies/usecases.py new file mode 100644 index 0000000..df9e50d --- /dev/null +++ b/api/infrastructure/dependencies/usecases.py @@ -0,0 +1,14 @@ +from typing import Annotated + +from fastapi import Depends + +from api.application.abstractions.uow import UnitOfWork +from api.application.usecase.create_user import CreateUser +from api.domain.user.repository import UserRepository + + +def provide_create_user( + user_repository: Annotated[UserRepository, Depends()], + uow: Annotated[UnitOfWork, Depends()], +) -> CreateUser: + return CreateUser(uow=uow, user_repository=user_repository) diff --git a/api/infrastructure/persistence/__init__.py b/api/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/infrastructure/persistence/repositories/__init__.py b/api/infrastructure/persistence/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/infrastructure/persistence/repositories/user_repository.py b/api/infrastructure/persistence/repositories/user_repository.py new file mode 100644 index 0000000..ea16a63 --- /dev/null +++ b/api/infrastructure/persistence/repositories/user_repository.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from api.domain.user import User, UserRepository + + +class SqlAlchemyUserRepository(UserRepository): + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def create_user(self, user: User) -> None: + pass + + async def get_user(self, filter: dict) -> User | None: + pass + + async def get_users(self) -> list[User]: + return [] diff --git a/api/infrastructure/persistence/uow.py b/api/infrastructure/persistence/uow.py new file mode 100644 index 0000000..22790bf --- /dev/null +++ b/api/infrastructure/persistence/uow.py @@ -0,0 +1,14 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from api.application.abstractions import UnitOfWork + + +class SqlAlchemyUnitOfWork(UnitOfWork): + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def commit(self) -> None: + await self.session.commit() + + async def rollback(self) -> None: + await self.session.rollback() diff --git a/api/presentation/routers/user.py b/api/presentation/routers/user.py index ad4a59e..c1eade3 100644 --- a/api/presentation/routers/user.py +++ b/api/presentation/routers/user.py @@ -1,6 +1,10 @@ -from fastapi import APIRouter +from typing import Annotated -from api.application.contracts.user import UserResponse +from fastapi import APIRouter, Depends + +from api.application.contracts.user import UserCreateRequest, UserResponse +from api.application.usecase.create_user import CreateUser +from api.infrastructure.dependencies.stub import Stub user_router = APIRouter(prefix="/users", tags=["Users"]) @@ -8,3 +12,11 @@ user_router = APIRouter(prefix="/users", tags=["Users"]) @user_router.get("/") async def get_all_users() -> list[UserResponse]: return [] + + +@user_router.post("/") +async def create_task( + request: UserCreateRequest, + usecase: Annotated[CreateUser, Depends(Stub(CreateUser))], +) -> None: + return await usecase.execute(request)