refactor: migrate to DDD architecture with Dishka DI

Complete architectural refactoring from simple MVC to Clean Architecture/DDD pattern:

Domain Layer:

- Add entities (Post, BaseEntity) with business logic

- Add value objects (Title, Content, Slug) with validation

- Add repository interfaces (PostRepository)

- Add domain exceptions

Application Layer:

- Add use cases (CreatePost, GetPost, UpdatePost, DeletePost, ListPosts, PublishPost)

- Add DTOs for data transfer

- Add TransactionManager interface

Infrastructure Layer:

- Add SQLAlchemy models and async database connection

- Add SQLAlchemyPostRepository implementation

- Add Dishka DI container with providers

- Add error handlers and middleware

Presentation Layer:

- Add FastAPI routes with Dishka integration

- Add Pydantic schemas

- Add dependency injection using FromDishka[T]

Other Changes:

- Remove old flat structure (api/, common/, core/, modules/)

- Add hatchling build system for package scripts

- Add blog CLI command

- Update AGENTS.md with new architecture docs

- All 48 tests passing, mypy clean, ruff clean
This commit is contained in:
2026-05-01 20:20:41 +03:00
parent b8334efa5a
commit 87b094220d
75 changed files with 2783 additions and 459 deletions

View File

@@ -1 +0,0 @@
"""API module - HTTP routes and endpoints."""

View File

@@ -1 +0,0 @@
"""API version 1 endpoints."""

View File

@@ -0,0 +1,28 @@
"""Application layer exports."""
from app.application.dtos import CreatePostDTO, PostResponseDTO, UpdatePostDTO
from app.application.interfaces import TransactionManager
from app.application.use_cases import (
CreatePostUseCase,
DeletePostUseCase,
GetPostUseCase,
ListPostsUseCase,
PublishPostUseCase,
UpdatePostUseCase,
)
__all__ = [
# DTOs
"CreatePostDTO",
"UpdatePostDTO",
"PostResponseDTO",
# Interfaces
"TransactionManager",
# Use Cases
"CreatePostUseCase",
"GetPostUseCase",
"UpdatePostUseCase",
"DeletePostUseCase",
"ListPostsUseCase",
"PublishPostUseCase",
]

View File

@@ -0,0 +1,5 @@
"""Application DTOs."""
from app.application.dtos.post import CreatePostDTO, PostResponseDTO, UpdatePostDTO
__all__ = ["CreatePostDTO", "UpdatePostDTO", "PostResponseDTO"]

View File

@@ -0,0 +1,39 @@
"""DTOs for post use cases."""
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass(frozen=True)
class CreatePostDTO:
"""DTO for creating a post."""
title: str
content: str
author_id: str
tags: list[str] | None = None
@dataclass(frozen=True)
class UpdatePostDTO:
"""DTO for updating a post."""
title: str | None = None
content: str | None = None
tags: list[str] | None = None
@dataclass(frozen=True)
class PostResponseDTO:
"""DTO for post response."""
id: UUID
title: str
content: str
slug: str
author_id: str
published: bool
tags: list[str]
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,5 @@
"""Application interfaces."""
from app.application.interfaces.transaction_manager import TransactionManager
__all__ = ["TransactionManager"]

View File

@@ -0,0 +1,17 @@
"""Transaction Manager interface for managing database transactions."""
from abc import ABC, abstractmethod
class TransactionManager(ABC):
"""Abstract Transaction Manager for controlling transaction boundaries."""
@abstractmethod
async def commit(self) -> None:
"""Commit the current transaction."""
...
@abstractmethod
async def rollback(self) -> None:
"""Rollback the current transaction."""
...

View File

@@ -0,0 +1,17 @@
"""Use cases."""
from app.application.use_cases.create_post import CreatePostUseCase
from app.application.use_cases.delete_post import DeletePostUseCase
from app.application.use_cases.get_post import GetPostUseCase
from app.application.use_cases.list_posts import ListPostsUseCase
from app.application.use_cases.publish_post import PublishPostUseCase
from app.application.use_cases.update_post import UpdatePostUseCase
__all__ = [
"CreatePostUseCase",
"GetPostUseCase",
"UpdatePostUseCase",
"DeletePostUseCase",
"ListPostsUseCase",
"PublishPostUseCase",
]

View File

@@ -0,0 +1,62 @@
"""Create post use case."""
from app.application.dtos.post import CreatePostDTO, PostResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.exceptions import AlreadyExistsException
from app.domain.repositories import PostRepository
class CreatePostUseCase:
"""Use case for creating a new blog post."""
def __init__(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> None:
self._post_repo = post_repo
self._tx_manager = tx_manager
async def execute(self, dto: CreatePostDTO) -> PostResponseDTO:
"""Execute the use case."""
# Generate slug from title
from app.domain.value_objects import Slug
slug = Slug.from_title(dto.title)
# Check if slug already exists
if await self._post_repo.slug_exists(slug.value):
raise AlreadyExistsException(
f"Post with slug '{slug.value}' already exists"
)
# Create domain entity
post = Post.create(
title_str=dto.title,
content_str=dto.content,
author_id=dto.author_id,
tags=dto.tags or [],
)
# Persist entity
await self._post_repo.add(post)
# Commit transaction
await self._tx_manager.commit()
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO."""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -0,0 +1,35 @@
"""Delete post use case."""
from uuid import UUID
from app.application.interfaces import TransactionManager
from app.domain.exceptions import ForbiddenException, NotFoundException
from app.domain.repositories import PostRepository
class DeletePostUseCase:
"""Use case for deleting a blog post."""
def __init__(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> None:
self._post_repo = post_repo
self._tx_manager = tx_manager
async def execute(self, post_id: UUID, current_user_id: str) -> None:
"""Execute the use case."""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
# Check authorization
if post.author_id != current_user_id:
raise ForbiddenException("You can only delete your own posts")
# Delete the post
await self._post_repo.delete(post_id)
# Commit transaction
await self._tx_manager.commit()

View File

@@ -0,0 +1,49 @@
"""Get post use case."""
from uuid import UUID
from app.application.dtos.post import PostResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.exceptions import NotFoundException
from app.domain.repositories import PostRepository
class GetPostUseCase:
"""Use case for retrieving a post by ID or slug."""
def __init__(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> None:
self._post_repo = post_repo
self._tx_manager = tx_manager
async def by_id(self, post_id: UUID) -> PostResponseDTO:
"""Get post by ID."""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
return self._map_to_dto(post)
async def by_slug(self, slug: str) -> PostResponseDTO:
"""Get post by slug."""
post = await self._post_repo.get_by_slug(slug)
if not post:
raise NotFoundException(f"Post with slug '{slug}' not found")
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO."""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -0,0 +1,57 @@
"""List posts use case."""
from app.application.dtos.post import PostResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.repositories import PostRepository
class ListPostsUseCase:
"""Use case for listing blog posts with filtering."""
def __init__(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> None:
self._post_repo = post_repo
self._tx_manager = tx_manager
async def all_posts(self) -> list[PostResponseDTO]:
"""Get all posts."""
posts = await self._post_repo.get_all()
return [self._map_to_dto(post) for post in posts]
async def published_posts(self) -> list[PostResponseDTO]:
"""Get all published posts."""
posts = await self._post_repo.get_published()
return [self._map_to_dto(post) for post in posts]
async def by_author(self, author_id: str) -> list[PostResponseDTO]:
"""Get posts by author."""
posts = await self._post_repo.get_by_author(author_id)
return [self._map_to_dto(post) for post in posts]
async def by_tag(self, tag: str) -> list[PostResponseDTO]:
"""Get posts by tag."""
posts = await self._post_repo.get_by_tag(tag)
return [self._map_to_dto(post) for post in posts]
async def search(self, query: str) -> list[PostResponseDTO]:
"""Search posts."""
posts = await self._post_repo.search(query)
return [self._map_to_dto(post) for post in posts]
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO."""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -0,0 +1,65 @@
"""Publish post use case."""
from uuid import UUID
from app.application.dtos.post import PostResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.exceptions import ForbiddenException, NotFoundException
from app.domain.repositories import PostRepository
class PublishPostUseCase:
"""Use case for publishing/unpublishing a blog post."""
def __init__(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> None:
self._post_repo = post_repo
self._tx_manager = tx_manager
async def publish(self, post_id: UUID, current_user_id: str) -> PostResponseDTO:
"""Publish a post."""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
if post.author_id != current_user_id:
raise ForbiddenException("You can only publish your own posts")
post.publish()
await self._post_repo.update(post)
await self._tx_manager.commit()
return self._map_to_dto(post)
async def unpublish(self, post_id: UUID, current_user_id: str) -> PostResponseDTO:
"""Unpublish a post."""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
if post.author_id != current_user_id:
raise ForbiddenException("You can only unpublish your own posts")
post.unpublish()
await self._post_repo.update(post)
await self._tx_manager.commit()
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO."""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -0,0 +1,73 @@
"""Update post use case."""
from uuid import UUID
from app.application.dtos.post import PostResponseDTO, UpdatePostDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.exceptions import ForbiddenException, NotFoundException
from app.domain.repositories import PostRepository
from app.domain.value_objects import Content, Title
class UpdatePostUseCase:
"""Use case for updating a blog post."""
def __init__(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> None:
self._post_repo = post_repo
self._tx_manager = tx_manager
async def execute(
self,
post_id: UUID,
dto: UpdatePostDTO,
current_user_id: str,
) -> PostResponseDTO:
"""Execute the use case."""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
# Check authorization
if post.author_id != current_user_id:
raise ForbiddenException("You can only update your own posts")
# Update fields
if dto.title is not None:
post.update_title(Title(dto.title))
if dto.content is not None:
post.update_content(Content(dto.content))
if dto.tags is not None:
# Replace all tags
for tag in post.tags[:]:
post.remove_tag(tag)
for tag in dto.tags:
post.add_tag(tag)
# Persist changes
await self._post_repo.update(post)
# Commit transaction
await self._tx_manager.commit()
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO."""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
tags=post.tags.copy(),
created_at=post.created_at,
updated_at=post.updated_at,
)

View File

@@ -1 +0,0 @@
"""Common utilities and shared components."""

View File

@@ -1,48 +0,0 @@
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException
from app.core.exceptions import AppException
class ErrorResponse(BaseModel):
status_code: int
message: str
details: dict[str, str] | None = None
timestamp: str
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"status_code": exc.status_code,
"message": exc.message,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"status_code": exc.status_code,
"message": str(exc.detail),
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(
AppException,
app_exception_handler, # type: ignore[arg-type]
)
app.add_exception_handler(
HTTPException,
http_exception_handler, # type: ignore[arg-type]
)

View File

@@ -1 +0,0 @@
"""Core module - shared functionality and configuration."""

View File

@@ -1,15 +0,0 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Blog API"
debug: bool = False
host: str = "0.0.0.0"
port: int = 8000
database_url: str | None = None
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()

View File

@@ -1,25 +0,0 @@
class AppException(Exception):
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
super().__init__(self.message)
class NotFoundError(AppException):
def __init__(self, message: str = "Resource not found"):
super().__init__(message, status_code=404)
class ValidationError(AppException):
def __init__(self, message: str = "Validation failed"):
super().__init__(message, status_code=400)
class UnauthorizedError(AppException):
def __init__(self, message: str = "Unauthorized"):
super().__init__(message, status_code=401)
class ForbiddenError(AppException):
def __init__(self, message: str = "Forbidden"):
super().__init__(message, status_code=403)

34
app/domain/__init__.py Normal file
View File

@@ -0,0 +1,34 @@
"""Domain layer exports."""
from app.domain.entities import BaseEntity, Post
from app.domain.exceptions import (
AlreadyExistsException,
DomainException,
ForbiddenException,
NotFoundException,
UnauthorizedException,
ValidationException,
)
from app.domain.repositories import PostRepository, Repository
from app.domain.value_objects import Content, Slug, Title, ValueObject
__all__ = [
# Entities
"BaseEntity",
"Post",
# Value Objects
"ValueObject",
"Title",
"Content",
"Slug",
# Repositories
"Repository",
"PostRepository",
# Exceptions
"DomainException",
"ValidationException",
"NotFoundException",
"AlreadyExistsException",
"UnauthorizedException",
"ForbiddenException",
]

View File

@@ -0,0 +1,6 @@
"""Domain entities."""
from app.domain.entities.base import BaseEntity
from app.domain.entities.post import Post
__all__ = ["BaseEntity", "Post"]

View File

@@ -0,0 +1,33 @@
"""Base entity for DDD domain layer."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
@dataclass(kw_only=True)
class BaseEntity(ABC):
"""Base class for all domain entities."""
id: UUID = field(default_factory=uuid4)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def __eq__(self, other: object) -> bool:
if not isinstance(other, BaseEntity):
return NotImplemented
return self.id == other.id
def __hash__(self) -> int:
return hash(self.id)
def touch(self) -> None:
"""Update the updated_at timestamp."""
self.updated_at = datetime.now(timezone.utc)
@abstractmethod
def to_dict(self) -> dict[str, Any]:
"""Convert entity to dictionary."""
...

View File

@@ -0,0 +1,88 @@
"""Domain entity for Blog Post."""
from dataclasses import dataclass, field
from typing import Any
from app.domain.entities.base import BaseEntity
from app.domain.value_objects.content import Content
from app.domain.value_objects.slug import Slug
from app.domain.value_objects.title import Title
@dataclass(kw_only=True)
class Post(BaseEntity):
"""Blog post domain entity."""
title: Title
content: Content
slug: Slug
author_id: str
published: bool = False
tags: list[str] = field(default_factory=list)
def publish(self) -> None:
"""Publish the post."""
self.published = True
self.touch()
def unpublish(self) -> None:
"""Unpublish the post."""
self.published = False
self.touch()
def update_content(self, content: Content) -> None:
"""Update post content."""
self.content = content
self.touch()
def update_title(self, title: Title) -> None:
"""Update post title and regenerate slug."""
self.title = title
self.slug = Slug.from_title(title.value)
self.touch()
def add_tag(self, tag: str) -> None:
"""Add a tag to the post."""
if tag not in self.tags:
self.tags.append(tag)
self.touch()
def remove_tag(self, tag: str) -> None:
"""Remove a tag from the post."""
if tag in self.tags:
self.tags.remove(tag)
self.touch()
def to_dict(self) -> dict[str, Any]:
"""Convert entity to dictionary."""
return {
"id": str(self.id),
"title": self.title.value,
"content": self.content.value,
"slug": self.slug.value,
"author_id": self.author_id,
"published": self.published,
"tags": self.tags.copy(),
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def create(
cls,
title_str: str,
content_str: str,
author_id: str,
tags: list[str] | None = None,
) -> "Post":
"""Factory method to create a new post."""
title = Title(title_str)
content = Content(content_str)
slug = Slug.from_title(title_str)
return cls(
title=title,
content=content,
slug=slug,
author_id=author_id,
tags=tags or [],
)

39
app/domain/exceptions.py Normal file
View File

@@ -0,0 +1,39 @@
"""Domain exceptions."""
class DomainException(Exception):
"""Base exception for domain layer."""
def __init__(self, message: str) -> None:
self.message = message
super().__init__(self.message)
class ValidationException(DomainException):
"""Raised when validation fails."""
pass
class NotFoundException(DomainException):
"""Raised when an entity is not found."""
pass
class AlreadyExistsException(DomainException):
"""Raised when trying to create an entity that already exists."""
pass
class UnauthorizedException(DomainException):
"""Raised when user is not authorized."""
pass
class ForbiddenException(DomainException):
"""Raised when access is forbidden."""
pass

View File

@@ -0,0 +1,6 @@
"""Repository interfaces."""
from app.domain.repositories.base import Repository
from app.domain.repositories.post import PostRepository
__all__ = ["Repository", "PostRepository"]

View File

@@ -0,0 +1,43 @@
"""Base repository interface for DDD."""
from abc import ABC, abstractmethod
from typing import Generic, TypeVar
from uuid import UUID
from app.domain.entities.base import BaseEntity
T = TypeVar("T", bound=BaseEntity)
class Repository(ABC, Generic[T]):
"""Generic repository interface."""
@abstractmethod
async def get_by_id(self, entity_id: UUID) -> T | None:
"""Get entity by ID."""
...
@abstractmethod
async def get_all(self) -> list[T]:
"""Get all entities."""
...
@abstractmethod
async def add(self, entity: T) -> None:
"""Add new entity."""
...
@abstractmethod
async def update(self, entity: T) -> None:
"""Update existing entity."""
...
@abstractmethod
async def delete(self, entity_id: UUID) -> None:
"""Delete entity by ID."""
...
@abstractmethod
async def exists(self, entity_id: UUID) -> bool:
"""Check if entity exists."""
...

View File

@@ -0,0 +1,40 @@
"""Post repository interface."""
from abc import abstractmethod
from app.domain.entities.post import Post
from app.domain.repositories.base import Repository
class PostRepository(Repository[Post]):
"""Repository interface for Blog Posts."""
@abstractmethod
async def get_by_slug(self, slug: str) -> Post | None:
"""Get post by slug."""
...
@abstractmethod
async def get_by_author(self, author_id: str) -> list[Post]:
"""Get all posts by author."""
...
@abstractmethod
async def get_published(self) -> list[Post]:
"""Get all published posts."""
...
@abstractmethod
async def get_by_tag(self, tag: str) -> list[Post]:
"""Get posts by tag."""
...
@abstractmethod
async def slug_exists(self, slug: str) -> bool:
"""Check if slug already exists."""
...
@abstractmethod
async def search(self, query: str) -> list[Post]:
"""Search posts by query string."""
...

View File

@@ -0,0 +1,8 @@
"""Value objects."""
from app.domain.value_objects.base import ValueObject
from app.domain.value_objects.content import Content
from app.domain.value_objects.slug import Slug
from app.domain.value_objects.title import Title
__all__ = ["ValueObject", "Title", "Content", "Slug"]

View File

@@ -0,0 +1,37 @@
"""Base value object for DDD domain layer."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
T = TypeVar("T")
@dataclass(frozen=True, slots=True)
class ValueObject(ABC, Generic[T]):
"""Base class for all value objects."""
value: T
def __post_init__(self) -> None:
self._validate()
@abstractmethod
def _validate(self) -> None:
"""Validate the value object. Raise ValueError if invalid."""
...
def __eq__(self, other: object) -> bool:
if not isinstance(other, ValueObject):
return False
return bool(self.value == other.value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return str(self.value)
def to_primitive(self) -> Any:
"""Convert value object to primitive type."""
return self.value

View File

@@ -0,0 +1,23 @@
"""Content value object."""
from dataclasses import dataclass
from app.domain.value_objects.base import ValueObject
@dataclass(frozen=True, slots=True)
class Content(ValueObject[str]):
"""Blog post content value object."""
MIN_LENGTH: int = 10
MAX_LENGTH: int = 50000
def _validate(self) -> None:
if not isinstance(self.value, str):
raise ValueError("Content must be a string")
if not self.value.strip():
raise ValueError("Content cannot be empty or whitespace")
if len(self.value) < self.MIN_LENGTH:
raise ValueError(f"Content must be at least {self.MIN_LENGTH} characters")
if len(self.value) > self.MAX_LENGTH:
raise ValueError(f"Content must be at most {self.MAX_LENGTH} characters")

View File

@@ -0,0 +1,41 @@
"""Slug value object for URL-friendly identifiers."""
import re
from dataclasses import dataclass
from app.domain.value_objects.base import ValueObject
@dataclass(frozen=True, slots=True)
class Slug(ValueObject[str]):
"""URL slug value object."""
MAX_LENGTH: int = 200
SLUG_PATTERN: str = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
def _validate(self) -> None:
if not isinstance(self.value, str):
raise ValueError("Slug must be a string")
if len(self.value) > self.MAX_LENGTH:
raise ValueError(f"Slug must be at most {self.MAX_LENGTH} characters")
if not re.match(self.SLUG_PATTERN, self.value):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
@classmethod
def from_title(cls, title: str) -> "Slug":
"""Generate slug from title."""
# Convert to lowercase, replace spaces with hyphens
slug = title.lower().strip()
# Keep only alphanumeric, spaces, and hyphens
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
# Replace spaces and multiple hyphens with single hyphen
slug = re.sub(r"[-\s]+", "-", slug)
# Limit length and strip hyphens
max_len = 200 # Same as MAX_LENGTH
slug = slug[:max_len].strip("-")
# Ensure we have at least one character
if not slug:
slug = "post"
return cls(value=slug)

View File

@@ -0,0 +1,23 @@
"""Title value object."""
from dataclasses import dataclass
from app.domain.value_objects.base import ValueObject
@dataclass(frozen=True, slots=True)
class Title(ValueObject[str]):
"""Blog post title value object."""
MIN_LENGTH: int = 3
MAX_LENGTH: int = 200
def _validate(self) -> None:
if not isinstance(self.value, str):
raise ValueError("Title must be a string")
if len(self.value) < self.MIN_LENGTH:
raise ValueError(f"Title must be at least {self.MIN_LENGTH} characters")
if len(self.value) > self.MAX_LENGTH:
raise ValueError(f"Title must be at most {self.MAX_LENGTH} characters")
if not self.value.strip():
raise ValueError("Title cannot be empty or whitespace")

View File

@@ -0,0 +1,35 @@
"""Infrastructure layer exports."""
from app.infrastructure.config import Settings, settings
from app.infrastructure.database import (
AsyncSessionLocal,
Base,
PostORM,
close_db,
engine,
get_session,
init_db,
)
from app.infrastructure.di import create_container
from app.infrastructure.middleware import register_exception_handlers
from app.infrastructure.repositories import SQLAlchemyPostRepository
__all__ = [
# Config
"Settings",
"settings",
# Database
"Base",
"PostORM",
"engine",
"AsyncSessionLocal",
"get_session",
"init_db",
"close_db",
# Repositories
"SQLAlchemyPostRepository",
# DI
"create_container",
# Middleware
"register_exception_handlers",
]

View File

@@ -0,0 +1,5 @@
"""Infrastructure configuration."""
from app.infrastructure.config.settings import Settings, settings
__all__ = ["Settings", "settings"]

View File

@@ -0,0 +1,31 @@
"""Application settings."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application configuration settings."""
# App settings
app_name: str = "Blog API"
debug: bool = False
host: str = "0.0.0.0"
port: int = 8000
# Database settings
database_url: str = "sqlite:///./blog.db"
database_echo: bool = False
# Security settings
secret_key: str = "your-secret-key-change-in-production"
access_token_expire_minutes: int = 30
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Global settings instance
settings = Settings()

View File

@@ -0,0 +1,22 @@
"""Database infrastructure."""
from app.infrastructure.database.connection import (
AsyncSessionLocal,
close_db,
engine,
get_session,
get_session_context,
init_db,
)
from app.infrastructure.database.models import Base, PostORM
__all__ = [
"Base",
"PostORM",
"engine",
"AsyncSessionLocal",
"get_session",
"get_session_context",
"init_db",
"close_db",
]

View File

@@ -0,0 +1,70 @@
"""Database connection and session management."""
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.infrastructure.config import settings
# Convert SQLite URL to async format if needed
def _get_database_url() -> str:
url = settings.database_url
if url.startswith("sqlite:///") and not url.startswith("sqlite+aiosqlite:///"):
return url.replace("sqlite:///", "sqlite+aiosqlite:///")
return url
# Create async engine
engine: AsyncEngine = create_async_engine(
_get_database_url(),
echo=settings.database_echo,
future=True,
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Get database session."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
@asynccontextmanager
async def get_session_context() -> AsyncGenerator[AsyncSession, None]:
"""Get database session as context manager."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db() -> None:
"""Initialize database tables."""
from app.infrastructure.database.models import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def close_db() -> None:
"""Close database connections."""
await engine.dispose()

View File

@@ -0,0 +1,40 @@
"""SQLAlchemy ORM models."""
from datetime import datetime, timezone
from uuid import uuid4
from sqlalchemy import JSON, Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
Base = declarative_base()
class PostORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for Blog Post."""
__tablename__ = "posts"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid4())
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
slug: Mapped[str] = mapped_column(
String(200), nullable=False, unique=True, index=True
)
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
published: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, index=True
)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)

View File

@@ -0,0 +1,7 @@
"""Dependency Injection using Dishka."""
from app.infrastructure.di.container import create_container
__all__ = [
"create_container",
]

View File

@@ -0,0 +1,20 @@
"""Dishka container setup."""
from dishka import AsyncContainer, make_async_container
from app.infrastructure.di.providers import (
DatabaseProvider,
RepositoryProvider,
TransactionManagerProvider,
UseCaseProvider,
)
def create_container() -> AsyncContainer:
"""Create and configure Dishka container."""
return make_async_container(
DatabaseProvider(),
RepositoryProvider(),
TransactionManagerProvider(),
UseCaseProvider(),
)

View File

@@ -0,0 +1,133 @@
"""Dishka providers for dependency injection."""
from typing import AsyncGenerator
from dishka import Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.application import (
CreatePostUseCase,
DeletePostUseCase,
GetPostUseCase,
ListPostsUseCase,
PublishPostUseCase,
UpdatePostUseCase,
)
from app.application.interfaces import TransactionManager
from app.domain.repositories import PostRepository
from app.infrastructure.database.connection import AsyncSessionLocal, engine
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
class DatabaseProvider(Provider):
"""Provider for database-related dependencies."""
@provide(scope=Scope.APP)
def get_engine(self) -> AsyncEngine:
"""Provide SQLAlchemy engine."""
return engine
@provide(scope=Scope.REQUEST)
async def get_session(self) -> AsyncGenerator[AsyncSession, None]:
"""Provide database session per request."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
class RepositoryProvider(Provider):
"""Provider for repository implementations."""
@provide(scope=Scope.REQUEST)
def get_post_repository(self, session: AsyncSession) -> PostRepository:
"""Provide PostRepository implementation."""
return SQLAlchemyPostRepository(session)
class TransactionManagerProvider(Provider):
"""Provider for transaction manager."""
@provide(scope=Scope.REQUEST)
def get_transaction_manager(self, session: AsyncSession) -> TransactionManager:
"""Provide TransactionManager implementation."""
from app.infrastructure.di.transaction_manager import SessionTransactionManager
return SessionTransactionManager(session)
class UseCaseProvider(Provider):
"""Provider for use cases."""
@provide(scope=Scope.REQUEST)
def get_create_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> CreatePostUseCase:
"""Provide CreatePostUseCase."""
return CreatePostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_get_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> GetPostUseCase:
"""Provide GetPostUseCase."""
return GetPostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_update_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> UpdatePostUseCase:
"""Provide UpdatePostUseCase."""
return UpdatePostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_delete_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> DeletePostUseCase:
"""Provide DeletePostUseCase."""
return DeletePostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_list_posts_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> ListPostsUseCase:
"""Provide ListPostsUseCase."""
return ListPostsUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_publish_post_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> PublishPostUseCase:
"""Provide PublishPostUseCase."""
return PublishPostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
)

View File

@@ -0,0 +1,24 @@
"""SQLAlchemy implementation of Transaction Manager."""
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.interfaces import TransactionManager
class SessionTransactionManager(TransactionManager):
"""SQLAlchemy Session-based Transaction Manager."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
self._committed: bool = False
async def commit(self) -> None:
"""Commit the current transaction."""
if not self._committed:
await self._session.commit()
self._committed = True
async def rollback(self) -> None:
"""Rollback the current transaction."""
if not self._committed:
await self._session.rollback()

View File

@@ -0,0 +1,15 @@
"""Infrastructure middleware."""
from app.infrastructure.middleware.error_handler import (
domain_exception_handler,
generic_exception_handler,
http_exception_handler,
register_exception_handlers,
)
__all__ = [
"domain_exception_handler",
"http_exception_handler",
"generic_exception_handler",
"register_exception_handlers",
]

View File

@@ -0,0 +1,93 @@
"""Exception handling middleware."""
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.domain.exceptions import (
AlreadyExistsException,
DomainException,
ForbiddenException,
NotFoundException,
UnauthorizedException,
ValidationException,
)
def get_status_code(exc: DomainException) -> int:
"""Map domain exceptions to HTTP status codes."""
match exc:
case ValidationException():
return 400
case UnauthorizedException():
return 401
case ForbiddenException():
return 403
case NotFoundException():
return 404
case AlreadyExistsException():
return 409
case _:
return 500
async def domain_exception_handler(
request: Request, exc: DomainException
) -> JSONResponse:
"""Handle domain exceptions."""
status_code = get_status_code(exc)
return JSONResponse(
status_code=status_code,
content={
"error": exc.__class__.__name__,
"message": exc.message,
"timestamp": datetime.now(timezone.utc).isoformat(),
"path": str(request.url.path),
},
)
async def http_exception_handler(
request: Request, exc: StarletteHTTPException
) -> JSONResponse:
"""Handle HTTP exceptions."""
return JSONResponse(
status_code=exc.status_code,
content={
"error": "HTTPException",
"message": str(exc.detail),
"timestamp": datetime.now(timezone.utc).isoformat(),
"path": str(request.url.path),
},
)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle generic exceptions."""
return JSONResponse(
status_code=500,
content={
"error": "InternalServerError",
"message": "An unexpected error occurred",
"timestamp": datetime.now(timezone.utc).isoformat(),
"path": str(request.url.path),
},
)
def register_exception_handlers(app: FastAPI) -> None:
"""Register all exception handlers with FastAPI app."""
if not isinstance(app, FastAPI):
raise TypeError("app must be a FastAPI instance")
# Domain exceptions
app.add_exception_handler(DomainException, domain_exception_handler) # type: ignore[arg-type]
# HTTP exceptions
app.add_exception_handler(StarletteHTTPException, http_exception_handler) # type: ignore[arg-type]
# Generic exceptions (only in production)
# In development, let FastAPI show detailed traceback
# app.add_exception_handler(Exception, generic_exception_handler)

View File

@@ -0,0 +1,5 @@
"""Repository implementations."""
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
__all__ = ["SQLAlchemyPostRepository"]

View File

@@ -0,0 +1,151 @@
"""SQLAlchemy implementation of PostRepository."""
from uuid import UUID
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities import Post
from app.domain.repositories import PostRepository
from app.domain.value_objects import Content, Slug, Title
from app.infrastructure.database.models import PostORM
class SQLAlchemyPostRepository(PostRepository):
"""SQLAlchemy implementation of Post repository."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
def _to_domain(self, orm: PostORM) -> Post:
"""Convert ORM model to domain entity."""
return Post(
id=UUID(orm.id),
title=Title(orm.title),
content=Content(orm.content),
slug=Slug(orm.slug),
author_id=orm.author_id,
published=orm.published,
tags=orm.tags or [],
created_at=orm.created_at,
updated_at=orm.updated_at,
)
def _to_orm(self, post: Post) -> PostORM:
"""Convert domain entity to ORM model."""
return PostORM(
id=str(post.id),
title=post.title.value,
content=post.content.value,
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
tags=post.tags,
created_at=post.created_at,
updated_at=post.updated_at,
)
async def get_by_id(self, entity_id: UUID) -> Post | None:
"""Get post by ID."""
result = await self._session.execute(
select(PostORM).where(PostORM.id == str(entity_id))
)
orm = result.scalar_one_or_none()
return self._to_domain(orm) if orm else None
async def get_all(self) -> list[Post]:
"""Get all posts."""
result = await self._session.execute(select(PostORM))
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def add(self, entity: Post) -> None:
"""Add new post."""
orm = self._to_orm(entity)
self._session.add(orm)
# Commit делает TransactionManager
async def update(self, entity: Post) -> None:
"""Update existing post."""
result = await self._session.execute(
select(PostORM).where(PostORM.id == str(entity.id))
)
orm = result.scalar_one()
orm.title = entity.title.value
orm.content = entity.content.value
orm.slug = entity.slug.value
orm.published = entity.published
orm.tags = entity.tags
orm.updated_at = entity.updated_at
# Commit делает TransactionManager
async def delete(self, entity_id: UUID) -> None:
"""Delete post by ID."""
result = await self._session.execute(
select(PostORM).where(PostORM.id == str(entity_id))
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)
async def exists(self, entity_id: UUID) -> bool:
"""Check if post exists."""
result = await self._session.execute(
select(PostORM).where(PostORM.id == str(entity_id))
)
return result.scalar_one_or_none() is not None
async def get_by_slug(self, slug: str) -> Post | None:
"""Get post by slug."""
result = await self._session.execute(
select(PostORM).where(PostORM.slug == slug)
)
orm = result.scalar_one_or_none()
return self._to_domain(orm) if orm else None
async def get_by_author(self, author_id: str) -> list[Post]:
"""Get posts by author."""
result = await self._session.execute(
select(PostORM).where(PostORM.author_id == author_id)
)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def get_published(self) -> list[Post]:
"""Get published posts."""
result = await self._session.execute(
select(PostORM).where(PostORM.published.is_(True))
)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def get_by_tag(self, tag: str) -> list[Post]:
"""Get posts by tag."""
result = await self._session.execute(
select(PostORM).where(PostORM.tags.contains([tag]))
)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def slug_exists(self, slug: str) -> bool:
"""Check if slug exists."""
result = await self._session.execute(
select(PostORM).where(PostORM.slug == slug)
)
return result.scalar_one_or_none() is not None
async def search(self, query: str) -> list[Post]:
"""Search posts."""
search_pattern = f"%{query}%"
result = await self._session.execute(
select(PostORM).where(
or_(
PostORM.title.ilike(search_pattern),
PostORM.content.ilike(search_pattern),
)
)
)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]

View File

@@ -1,22 +1,84 @@
"""Application entry point with DDD architecture."""
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import uvicorn
from dishka import make_async_container
from dishka.integrations.fastapi import setup_dishka
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.infrastructure import close_db, init_db, register_exception_handlers, settings
from app.infrastructure.di.providers import (
DatabaseProvider,
RepositoryProvider,
TransactionManagerProvider,
UseCaseProvider,
)
from app.presentation import router
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan manager."""
# Startup
await init_db()
yield
# Shutdown
await close_db()
def app_factory() -> FastAPI:
app = FastAPI(lifespan=lifespan)
"""Create and configure FastAPI application."""
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
lifespan=lifespan,
docs_url="/docs" if settings.debug else None,
redoc_url="/redoc" if settings.debug else None,
)
# Setup Dishka DI container
container = make_async_container(
DatabaseProvider(),
RepositoryProvider(),
TransactionManagerProvider(),
UseCaseProvider(),
)
setup_dishka(container, app)
# Register exception handlers
register_exception_handlers(app)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API routes
app.include_router(router, prefix="/api")
# Health check endpoint
@app.get("/health", tags=["health"])
async def health_check() -> dict[str, str]:
return {"status": "ok", "app": settings.app_name}
return app
def main() -> None:
uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000)
"""Run the application."""
uvicorn.run(
app_factory,
factory=True,
host=settings.host,
port=settings.port,
)
if __name__ == "__main__":

View File

@@ -1 +0,0 @@
"""Feature modules - business logic organized by domain."""

View File

@@ -0,0 +1,17 @@
"""Presentation layer exports."""
from app.presentation.api import router
from app.presentation.schemas import (
PostCreateSchema,
PostListResponseSchema,
PostResponseSchema,
PostUpdateSchema,
)
__all__ = [
"router",
"PostCreateSchema",
"PostUpdateSchema",
"PostResponseSchema",
"PostListResponseSchema",
]

View File

@@ -0,0 +1,8 @@
"""API router configuration."""
from fastapi import APIRouter
from app.presentation.api.v1 import router as v1_router
router = APIRouter()
router.include_router(v1_router)

View File

@@ -0,0 +1,34 @@
"""API dependencies using Dishka."""
from typing import Annotated
from dishka.integrations.fastapi import FromDishka
from fastapi import Depends, Header
from app.application import (
CreatePostUseCase,
DeletePostUseCase,
GetPostUseCase,
ListPostsUseCase,
PublishPostUseCase,
UpdatePostUseCase,
)
# Use case dependencies - injected via Dishka
CreatePostDep = FromDishka[CreatePostUseCase]
GetPostDep = FromDishka[GetPostUseCase]
UpdatePostDep = FromDishka[UpdatePostUseCase]
DeletePostDep = FromDishka[DeletePostUseCase]
ListPostsDep = FromDishka[ListPostsUseCase]
PublishPostDep = FromDishka[PublishPostUseCase]
# Mock current user dependency (replace with real auth)
async def get_current_user_id(
x_user_id: Annotated[str | None, Header()] = "user-123",
) -> str:
"""Get current user ID from header."""
return x_user_id or "user-123"
CurrentUserDep = Annotated[str, Depends(get_current_user_id)]

View File

@@ -0,0 +1,8 @@
"""API v1 router."""
from fastapi import APIRouter
from app.presentation.api.v1.posts import router as posts_router
router = APIRouter(prefix="/v1")
router.include_router(posts_router)

View File

@@ -0,0 +1,211 @@
"""Posts API routes."""
from uuid import UUID
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, status
from app.application.dtos import CreatePostDTO, UpdatePostDTO
from app.presentation.api.deps import (
CreatePostDep,
CurrentUserDep,
DeletePostDep,
GetPostDep,
ListPostsDep,
PublishPostDep,
UpdatePostDep,
)
from app.presentation.schemas import (
PostCreateSchema,
PostListResponseSchema,
PostResponseSchema,
PostUpdateSchema,
)
router = APIRouter(prefix="/posts", tags=["posts"], route_class=DishkaRoute)
@router.post(
"",
response_model=PostResponseSchema,
status_code=status.HTTP_201_CREATED,
summary="Create a new post",
)
async def create_post(
schema: PostCreateSchema,
use_case: CreatePostDep,
current_user_id: CurrentUserDep,
) -> PostResponseSchema:
"""Create a new blog post."""
dto = CreatePostDTO(
title=schema.title,
content=schema.content,
author_id=current_user_id,
tags=schema.tags,
)
result = await use_case.execute(dto)
return PostResponseSchema(**result.__dict__)
@router.get(
"",
response_model=PostListResponseSchema,
summary="List all posts",
)
async def list_posts(use_case: ListPostsDep) -> PostListResponseSchema:
"""Get all blog posts."""
results = await use_case.all_posts()
items = [PostResponseSchema(**r.__dict__) for r in results]
return PostListResponseSchema(items=items, total=len(items))
@router.get(
"/published",
response_model=PostListResponseSchema,
summary="List published posts",
)
async def list_published_posts(
use_case: ListPostsDep,
) -> PostListResponseSchema:
"""Get all published blog posts."""
results = await use_case.published_posts()
items = [PostResponseSchema(**r.__dict__) for r in results]
return PostListResponseSchema(items=items, total=len(items))
@router.get(
"/search",
response_model=PostListResponseSchema,
summary="Search posts",
)
async def search_posts(
query: str,
use_case: ListPostsDep,
) -> PostListResponseSchema:
"""Search posts by query."""
results = await use_case.search(query)
items = [PostResponseSchema(**r.__dict__) for r in results]
return PostListResponseSchema(items=items, total=len(items))
@router.get(
"/by-tag/{tag}",
response_model=PostListResponseSchema,
summary="Get posts by tag",
)
async def get_posts_by_tag(
tag: str,
use_case: ListPostsDep,
) -> PostListResponseSchema:
"""Get posts by tag."""
results = await use_case.by_tag(tag)
items = [PostResponseSchema(**r.__dict__) for r in results]
return PostListResponseSchema(items=items, total=len(items))
@router.get(
"/by-author/{author_id}",
response_model=PostListResponseSchema,
summary="Get posts by author",
)
async def get_posts_by_author(
author_id: str,
use_case: ListPostsDep,
) -> PostListResponseSchema:
"""Get posts by author."""
results = await use_case.by_author(author_id)
items = [PostResponseSchema(**r.__dict__) for r in results]
return PostListResponseSchema(items=items, total=len(items))
@router.get(
"/{post_id}",
response_model=PostResponseSchema,
summary="Get post by ID",
)
async def get_post(
post_id: UUID,
use_case: GetPostDep,
) -> PostResponseSchema:
"""Get a post by its ID."""
result = await use_case.by_id(post_id)
return PostResponseSchema(**result.__dict__)
@router.get(
"/slug/{slug}",
response_model=PostResponseSchema,
summary="Get post by slug",
)
async def get_post_by_slug(
slug: str,
use_case: GetPostDep,
) -> PostResponseSchema:
"""Get a post by its slug."""
result = await use_case.by_slug(slug)
return PostResponseSchema(**result.__dict__)
@router.patch(
"/{post_id}",
response_model=PostResponseSchema,
summary="Update post",
)
async def update_post(
post_id: UUID,
schema: PostUpdateSchema,
use_case: UpdatePostDep,
current_user_id: CurrentUserDep,
) -> PostResponseSchema:
"""Update a post."""
dto = UpdatePostDTO(
title=schema.title,
content=schema.content,
tags=schema.tags,
)
result = await use_case.execute(post_id, dto, current_user_id)
return PostResponseSchema(**result.__dict__)
@router.delete(
"/{post_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete post",
)
async def delete_post(
post_id: UUID,
use_case: DeletePostDep,
current_user_id: CurrentUserDep,
) -> None:
"""Delete a post."""
await use_case.execute(post_id, current_user_id)
@router.post(
"/{post_id}/publish",
response_model=PostResponseSchema,
summary="Publish post",
)
async def publish_post(
post_id: UUID,
use_case: PublishPostDep,
current_user_id: CurrentUserDep,
) -> PostResponseSchema:
"""Publish a post."""
result = await use_case.publish(post_id, current_user_id)
return PostResponseSchema(**result.__dict__)
@router.post(
"/{post_id}/unpublish",
response_model=PostResponseSchema,
summary="Unpublish post",
)
async def unpublish_post(
post_id: UUID,
use_case: PublishPostDep,
current_user_id: CurrentUserDep,
) -> PostResponseSchema:
"""Unpublish a post."""
result = await use_case.unpublish(post_id, current_user_id)
return PostResponseSchema(**result.__dict__)

View File

@@ -0,0 +1,21 @@
"""Presentation schemas."""
from app.presentation.schemas.post import (
PostBaseSchema,
PostCreateSchema,
PostListResponseSchema,
PostPublishSchema,
PostResponseSchema,
PostSearchSchema,
PostUpdateSchema,
)
__all__ = [
"PostBaseSchema",
"PostCreateSchema",
"PostUpdateSchema",
"PostResponseSchema",
"PostListResponseSchema",
"PostSearchSchema",
"PostPublishSchema",
]

View File

@@ -0,0 +1,66 @@
"""API schemas for posts."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class PostBaseSchema(BaseModel):
"""Base schema for posts."""
model_config = ConfigDict(from_attributes=True)
title: str = Field(..., min_length=3, max_length=200)
content: str = Field(..., min_length=10, max_length=50000)
class PostCreateSchema(PostBaseSchema):
"""Schema for creating a post."""
tags: list[str] = Field(default_factory=list)
class PostUpdateSchema(BaseModel):
"""Schema for updating a post."""
model_config = ConfigDict(from_attributes=True)
title: str | None = Field(None, min_length=3, max_length=200)
content: str | None = Field(None, min_length=10, max_length=50000)
tags: list[str] | None = None
class PostResponseSchema(BaseModel):
"""Schema for post response."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
content: str
slug: str
author_id: str
published: bool
tags: list[str]
created_at: datetime
updated_at: datetime
class PostListResponseSchema(BaseModel):
"""Schema for list of posts response."""
items: list[PostResponseSchema]
total: int
class PostSearchSchema(BaseModel):
"""Schema for searching posts."""
query: str = Field(..., min_length=1, max_length=100)
class PostPublishSchema(BaseModel):
"""Schema for publishing/unpublishing a post."""
published: bool