init
This commit is contained in:
14
app/application/users/dto.py
Normal file
14
app/application/users/dto.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserDTO:
|
||||
id: UUID
|
||||
email: str
|
||||
full_name: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
15
app/application/users/ports/unit_of_work.py
Normal file
15
app/application/users/ports/unit_of_work.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from app.application.users.ports.user_repository import UserRepository
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UnitOfWork(Protocol):
|
||||
users: UserRepository
|
||||
|
||||
async def __aenter__(self) -> "UnitOfWork": ...
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None: ...
|
||||
async def commit(self) -> None: ...
|
||||
async def rollback(self) -> None: ...
|
||||
12
app/application/users/ports/user_repository.py
Normal file
12
app/application/users/ports/user_repository.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.users.entities import User
|
||||
|
||||
|
||||
class UserRepository(Protocol):
|
||||
async def add(self, user: User) -> None: ...
|
||||
async def get_by_id(self, user_id: UUID) -> User | None: ...
|
||||
async def get_by_email(self, email: str) -> User | None: ...
|
||||
55
app/application/users/use_cases/create_user.py
Normal file
55
app/application/users/use_cases/create_user.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from app.application.users.dto import UserDTO
|
||||
from app.application.users.ports.unit_of_work import UnitOfWork
|
||||
from app.domain.users.entities import User
|
||||
from app.domain.users.services import ensure_unique_email
|
||||
from app.domain.users.value_objects import Email
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreateUserCommand:
|
||||
email: str
|
||||
full_name: str
|
||||
|
||||
|
||||
class CreateUserUseCase:
|
||||
"""
|
||||
Inbound port (use case).
|
||||
Outbound ports: UnitOfWork -> UserRepository, and email integration.
|
||||
"""
|
||||
|
||||
def __init__(self, uow: UnitOfWork, welcome_email_sender) -> None:
|
||||
self._uow = uow
|
||||
self._welcome_email_sender = welcome_email_sender
|
||||
|
||||
async def execute(self, cmd: CreateUserCommand) -> UserDTO:
|
||||
email_vo = Email(cmd.email)
|
||||
async with self._uow:
|
||||
existing = await self._uow.users.get_by_email(str(email_vo))
|
||||
ensure_unique_email(existing_user=existing)
|
||||
|
||||
user = User.register(
|
||||
user_id=uuid4(), email=email_vo, full_name=cmd.full_name
|
||||
)
|
||||
await self._uow.users.add(user)
|
||||
await self._uow.commit()
|
||||
|
||||
for ev in user.pull_events():
|
||||
from app.domain.users.events import UserRegistered
|
||||
|
||||
if isinstance(ev, UserRegistered):
|
||||
await self._welcome_email_sender.send_welcome(
|
||||
to_email=ev.email, full_name=cmd.full_name
|
||||
)
|
||||
|
||||
return UserDTO(
|
||||
id=user.id,
|
||||
email=str(user.email),
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
32
app/application/users/use_cases/get_user.py
Normal file
32
app/application/users/use_cases/get_user.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.users.dto import UserDTO
|
||||
from app.application.users.ports.unit_of_work import UnitOfWork
|
||||
from app.domain.users.exceptions import UserNotFound
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GetUserQuery:
|
||||
user_id: UUID
|
||||
|
||||
|
||||
class GetUserUseCase:
|
||||
def __init__(self, uow: UnitOfWork) -> None:
|
||||
self._uow = uow
|
||||
|
||||
async def execute(self, q: GetUserQuery) -> UserDTO:
|
||||
async with self._uow:
|
||||
user = await self._uow.users.get_by_id(q.user_id)
|
||||
if user is None:
|
||||
raise UserNotFound("User not found")
|
||||
|
||||
return UserDTO(
|
||||
id=user.id,
|
||||
email=str(user.email),
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
19
app/domain/shared/base_entity.py
Normal file
19
app/domain/shared/base_entity.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
TId = TypeVar("TId")
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Entity(Generic[TId]):
|
||||
id: TId
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Entity):
|
||||
return False
|
||||
return self.id == other.id and self.__class__ is other.__class__
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.__class__, self.id))
|
||||
10
app/domain/shared/base_value_object.py
Normal file
10
app/domain/shared/base_value_object.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValueObject:
|
||||
"""Marker base class for value objects."""
|
||||
|
||||
pass
|
||||
38
app/domain/users/entities.py
Normal file
38
app/domain/users/entities.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.shared.base_entity import Entity
|
||||
from app.domain.users.events import UserRegistered
|
||||
from app.domain.users.value_objects import Email
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class User(Entity[UUID]):
|
||||
email: Email
|
||||
full_name: str
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
_events: list[object] = field(default_factory=list, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def events(self) -> list[object]:
|
||||
return list(self._events)
|
||||
|
||||
def pull_events(self) -> list[object]:
|
||||
ev, self._events = self._events, []
|
||||
return ev
|
||||
|
||||
@classmethod
|
||||
def register(cls, *, user_id: UUID, email: Email, full_name: str) -> "User":
|
||||
user = cls(id=user_id, email=email, full_name=full_name)
|
||||
user._events.append(
|
||||
UserRegistered(
|
||||
user_id=user_id,
|
||||
email=str(email),
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
return user
|
||||
12
app/domain/users/events.py
Normal file
12
app/domain/users/events.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserRegistered:
|
||||
user_id: UUID
|
||||
email: str
|
||||
occurred_at: datetime
|
||||
14
app/domain/users/exceptions.py
Normal file
14
app/domain/users/exceptions.py
Normal file
@@ -0,0 +1,14 @@
|
||||
class DomainError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEmail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserAlreadyExists(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserNotFound(Exception):
|
||||
pass
|
||||
9
app/domain/users/services.py
Normal file
9
app/domain/users/services.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.users.entities import User
|
||||
from app.domain.users.exceptions import UserAlreadyExists
|
||||
|
||||
|
||||
def ensure_unique_email(*, existing_user: User | None) -> None:
|
||||
if existing_user is not None:
|
||||
raise UserAlreadyExists("User with this email already exists.")
|
||||
21
app/domain/users/value_objects.py
Normal file
21
app/domain/users/value_objects.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.domain.shared.base_value_object import ValueObject
|
||||
from app.domain.users.exceptions import InvalidEmail
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Email(ValueObject):
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not _EMAIL_RE.match(self.value):
|
||||
raise InvalidEmail(f"Invalid email: {self.value}")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
27
app/infrastructure/db/sqlalchemy/models.py
Normal file
27
app/infrastructure/db/sqlalchemy/models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserModel(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(320), primary_key=True, default=lambda: str(uuid4())
|
||||
)
|
||||
full_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(320), unique=True, index=True, nullable=False
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.users.ports.user_repository import UserRepository
|
||||
from app.domain.users.entities import User
|
||||
from app.domain.users.value_objects import Email
|
||||
from app.infrastructure.db.sqlalchemy.models import UserModel
|
||||
|
||||
|
||||
class SqlAlchemyUserRepository(UserRepository):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def add(self, user: User) -> None:
|
||||
model = UserModel(
|
||||
id=str(user.id),
|
||||
email=str(user.email),
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
self._session.add(model)
|
||||
|
||||
async def get_by_id(self, user_id: UUID) -> User | None:
|
||||
stmt = select(UserModel).where(UserModel.id == str(user_id))
|
||||
res = await self._session.execute(stmt)
|
||||
model = res.scalar_one_or_none()
|
||||
if model is None:
|
||||
return None
|
||||
return User(
|
||||
id=UUID(model.id),
|
||||
email=Email(model.email),
|
||||
full_name=model.full_name,
|
||||
is_active=model.is_active,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
stmt = select(UserModel).where(UserModel.email == email)
|
||||
res = await self._session.execute(stmt)
|
||||
model = res.scalar_one_or_none()
|
||||
if model is None:
|
||||
return None
|
||||
return User(
|
||||
id=UUID(model.id),
|
||||
email=Email(model.email),
|
||||
full_name=model.full_name,
|
||||
is_active=model.is_active,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
25
app/infrastructure/db/sqlalchemy/session.py
Normal file
25
app/infrastructure/db/sqlalchemy/session.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./app.db")
|
||||
|
||||
_engine: AsyncEngine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||
_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=_engine, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
def get_engine() -> AsyncEngine:
|
||||
return _engine
|
||||
|
||||
|
||||
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||
return _sessionmaker
|
||||
31
app/infrastructure/db/sqlalchemy/uow.py
Normal file
31
app/infrastructure/db/sqlalchemy/uow.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.application.users.ports.unit_of_work import UnitOfWork
|
||||
from app.infrastructure.db.sqlalchemy.repositories.user_repository import (
|
||||
SqlAlchemyUserRepository,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyUnitOfWork(UnitOfWork):
|
||||
def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sessionmaker = sessionmaker
|
||||
self._session: AsyncSession | None = None
|
||||
self.users = None
|
||||
|
||||
async def __aenter__(self) -> "SqlAlchemyUnitOfWork":
|
||||
self._session = self._sessionmaker()
|
||||
self.users = SqlAlchemyUserRepository(self._session)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
if exc:
|
||||
await self.rollback()
|
||||
await self._session.close() # type: ignore[union-attr]
|
||||
|
||||
async def commit(self) -> None:
|
||||
await self._session.commit() # type: ignore[union-attr]
|
||||
|
||||
async def rollback(self) -> None:
|
||||
await self._session.rollback() # type: ignore[union-attr]
|
||||
32
app/infrastructure/integrations/email/smtp_sender.py
Normal file
32
app/infrastructure/integrations/email/smtp_sender.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from email.message import EmailMessage
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
|
||||
class SmtpWelcomeEmailSender:
|
||||
def __init__(self) -> None:
|
||||
self._host = os.getenv("SMTP_HOST", "localhost")
|
||||
self._port = int(os.getenv("SMTP_PORT", "1025"))
|
||||
self._username = os.getenv("SMTP_USERNAME")
|
||||
self._password = os.getenv("SMTP_PASSWORD")
|
||||
self._from_email = os.getenv("SMTP_FROM_EMAIL", "noreply@example.com")
|
||||
self._use_tls = os.getenv("SMTP_USE_TLS", "false").lower() == "true"
|
||||
|
||||
async def send_welcome(self, *, to_email: str, full_name: str) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = self._from_email
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "Welcome!"
|
||||
msg.set_content(f"Hi, {full_name}! Welcome to our service.")
|
||||
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=self._host,
|
||||
port=self._port,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
start_tls=self._use_tls,
|
||||
)
|
||||
10
app/presentation/api/v1/health.py
Normal file
10
app/presentation/api/v1/health.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
44
app/presentation/api/v1/users.py
Normal file
44
app/presentation/api/v1/users.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.application.users.use_cases.create_user import (
|
||||
CreateUserCommand,
|
||||
CreateUserUseCase,
|
||||
)
|
||||
from app.application.users.use_cases.get_user import GetUserQuery, GetUserUseCase
|
||||
from app.domain.users.exceptions import InvalidEmail, UserAlreadyExists, UserNotFound
|
||||
from app.presentation.dependencies.users import get_create_user_uc, get_get_user_uc
|
||||
from app.presentation.schemas.users import CreateUserRequest, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
req: CreateUserRequest,
|
||||
uc: CreateUserUseCase = Depends(get_create_user_uc),
|
||||
) -> UserResponse:
|
||||
try:
|
||||
dto = await uc.execute(
|
||||
CreateUserCommand(email=str(req.email), full_name=req.full_name)
|
||||
)
|
||||
return UserResponse(**dto.__dict__)
|
||||
except InvalidEmail as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except UserAlreadyExists as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: UUID,
|
||||
uc: GetUserUseCase = Depends(get_get_user_uc),
|
||||
) -> UserResponse:
|
||||
try:
|
||||
dto = await uc.execute(GetUserQuery(user_id=user_id))
|
||||
return UserResponse(**dto.__dict__)
|
||||
except UserNotFound as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
30
app/presentation/dependencies/users.py
Normal file
30
app/presentation/dependencies/users.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from app.application.users.use_cases.create_user import CreateUserUseCase
|
||||
from app.application.users.use_cases.get_user import GetUserUseCase
|
||||
from app.infrastructure.db.sqlalchemy.session import get_sessionmaker
|
||||
from app.infrastructure.db.sqlalchemy.uow import SqlAlchemyUnitOfWork
|
||||
from app.infrastructure.integrations.email.smtp_sender import SmtpWelcomeEmailSender
|
||||
|
||||
|
||||
def get_uow() -> SqlAlchemyUnitOfWork:
|
||||
return SqlAlchemyUnitOfWork(get_sessionmaker())
|
||||
|
||||
|
||||
def get_email_sender() -> SmtpWelcomeEmailSender:
|
||||
return SmtpWelcomeEmailSender()
|
||||
|
||||
|
||||
def get_create_user_uc(
|
||||
uow: SqlAlchemyUnitOfWork = Depends(get_uow),
|
||||
email_sender: SmtpWelcomeEmailSender = Depends(get_email_sender),
|
||||
) -> CreateUserUseCase:
|
||||
return CreateUserUseCase(uow=uow, welcome_email_sender=email_sender)
|
||||
|
||||
|
||||
def get_get_user_uc(
|
||||
uow: SqlAlchemyUnitOfWork = Depends(get_uow),
|
||||
) -> GetUserUseCase:
|
||||
return GetUserUseCase(uow=uow)
|
||||
28
app/presentation/main.py
Normal file
28
app/presentation/main.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.infrastructure.db.sqlalchemy.models import Base
|
||||
from app.infrastructure.db.sqlalchemy.session import get_engine
|
||||
from app.presentation.api.v1.health import router as health_router
|
||||
from app.presentation.api.v1.users import router as users_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="DDD Onion FastAPI Demo", version="1.0.0")
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(users_router)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup() -> None:
|
||||
# демо-миграция: создать таблицы при старте
|
||||
# в проде делай Alembic, но файл models.py уже готов.
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
19
app/presentation/schemas/users.py
Normal file
19
app/presentation/schemas/users.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: EmailStr
|
||||
full_name: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
Reference in New Issue
Block a user