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:
@@ -1 +0,0 @@
|
||||
"""API module - HTTP routes and endpoints."""
|
||||
@@ -1 +0,0 @@
|
||||
"""API version 1 endpoints."""
|
||||
28
app/application/__init__.py
Normal file
28
app/application/__init__.py
Normal 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",
|
||||
]
|
||||
5
app/application/dtos/__init__.py
Normal file
5
app/application/dtos/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Application DTOs."""
|
||||
|
||||
from app.application.dtos.post import CreatePostDTO, PostResponseDTO, UpdatePostDTO
|
||||
|
||||
__all__ = ["CreatePostDTO", "UpdatePostDTO", "PostResponseDTO"]
|
||||
39
app/application/dtos/post.py
Normal file
39
app/application/dtos/post.py
Normal 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
|
||||
5
app/application/interfaces/__init__.py
Normal file
5
app/application/interfaces/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Application interfaces."""
|
||||
|
||||
from app.application.interfaces.transaction_manager import TransactionManager
|
||||
|
||||
__all__ = ["TransactionManager"]
|
||||
17
app/application/interfaces/transaction_manager.py
Normal file
17
app/application/interfaces/transaction_manager.py
Normal 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."""
|
||||
...
|
||||
17
app/application/use_cases/__init__.py
Normal file
17
app/application/use_cases/__init__.py
Normal 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",
|
||||
]
|
||||
62
app/application/use_cases/create_post.py
Normal file
62
app/application/use_cases/create_post.py
Normal 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,
|
||||
)
|
||||
35
app/application/use_cases/delete_post.py
Normal file
35
app/application/use_cases/delete_post.py
Normal 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()
|
||||
49
app/application/use_cases/get_post.py
Normal file
49
app/application/use_cases/get_post.py
Normal 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,
|
||||
)
|
||||
57
app/application/use_cases/list_posts.py
Normal file
57
app/application/use_cases/list_posts.py
Normal 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,
|
||||
)
|
||||
65
app/application/use_cases/publish_post.py
Normal file
65
app/application/use_cases/publish_post.py
Normal 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,
|
||||
)
|
||||
73
app/application/use_cases/update_post.py
Normal file
73
app/application/use_cases/update_post.py
Normal 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,
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""Common utilities and shared components."""
|
||||
@@ -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]
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""Core module - shared functionality and configuration."""
|
||||
@@ -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()
|
||||
@@ -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
34
app/domain/__init__.py
Normal 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",
|
||||
]
|
||||
6
app/domain/entities/__init__.py
Normal file
6
app/domain/entities/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Domain entities."""
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
from app.domain.entities.post import Post
|
||||
|
||||
__all__ = ["BaseEntity", "Post"]
|
||||
33
app/domain/entities/base.py
Normal file
33
app/domain/entities/base.py
Normal 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."""
|
||||
...
|
||||
88
app/domain/entities/post.py
Normal file
88
app/domain/entities/post.py
Normal 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
39
app/domain/exceptions.py
Normal 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
|
||||
6
app/domain/repositories/__init__.py
Normal file
6
app/domain/repositories/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Repository interfaces."""
|
||||
|
||||
from app.domain.repositories.base import Repository
|
||||
from app.domain.repositories.post import PostRepository
|
||||
|
||||
__all__ = ["Repository", "PostRepository"]
|
||||
43
app/domain/repositories/base.py
Normal file
43
app/domain/repositories/base.py
Normal 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."""
|
||||
...
|
||||
40
app/domain/repositories/post.py
Normal file
40
app/domain/repositories/post.py
Normal 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."""
|
||||
...
|
||||
8
app/domain/value_objects/__init__.py
Normal file
8
app/domain/value_objects/__init__.py
Normal 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"]
|
||||
37
app/domain/value_objects/base.py
Normal file
37
app/domain/value_objects/base.py
Normal 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
|
||||
23
app/domain/value_objects/content.py
Normal file
23
app/domain/value_objects/content.py
Normal 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")
|
||||
41
app/domain/value_objects/slug.py
Normal file
41
app/domain/value_objects/slug.py
Normal 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)
|
||||
23
app/domain/value_objects/title.py
Normal file
23
app/domain/value_objects/title.py
Normal 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")
|
||||
35
app/infrastructure/__init__.py
Normal file
35
app/infrastructure/__init__.py
Normal 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",
|
||||
]
|
||||
5
app/infrastructure/config/__init__.py
Normal file
5
app/infrastructure/config/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Infrastructure configuration."""
|
||||
|
||||
from app.infrastructure.config.settings import Settings, settings
|
||||
|
||||
__all__ = ["Settings", "settings"]
|
||||
31
app/infrastructure/config/settings.py
Normal file
31
app/infrastructure/config/settings.py
Normal 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()
|
||||
22
app/infrastructure/database/__init__.py
Normal file
22
app/infrastructure/database/__init__.py
Normal 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",
|
||||
]
|
||||
70
app/infrastructure/database/connection.py
Normal file
70
app/infrastructure/database/connection.py
Normal 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()
|
||||
40
app/infrastructure/database/models.py
Normal file
40
app/infrastructure/database/models.py
Normal 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,
|
||||
)
|
||||
7
app/infrastructure/di/__init__.py
Normal file
7
app/infrastructure/di/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Dependency Injection using Dishka."""
|
||||
|
||||
from app.infrastructure.di.container import create_container
|
||||
|
||||
__all__ = [
|
||||
"create_container",
|
||||
]
|
||||
20
app/infrastructure/di/container.py
Normal file
20
app/infrastructure/di/container.py
Normal 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(),
|
||||
)
|
||||
133
app/infrastructure/di/providers.py
Normal file
133
app/infrastructure/di/providers.py
Normal 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,
|
||||
)
|
||||
24
app/infrastructure/di/transaction_manager.py
Normal file
24
app/infrastructure/di/transaction_manager.py
Normal 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()
|
||||
15
app/infrastructure/middleware/__init__.py
Normal file
15
app/infrastructure/middleware/__init__.py
Normal 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",
|
||||
]
|
||||
93
app/infrastructure/middleware/error_handler.py
Normal file
93
app/infrastructure/middleware/error_handler.py
Normal 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)
|
||||
5
app/infrastructure/repositories/__init__.py
Normal file
5
app/infrastructure/repositories/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Repository implementations."""
|
||||
|
||||
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
|
||||
|
||||
__all__ = ["SQLAlchemyPostRepository"]
|
||||
151
app/infrastructure/repositories/post.py
Normal file
151
app/infrastructure/repositories/post.py
Normal 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]
|
||||
66
app/main.py
66
app/main.py
@@ -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__":
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Feature modules - business logic organized by domain."""
|
||||
17
app/presentation/__init__.py
Normal file
17
app/presentation/__init__.py
Normal 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",
|
||||
]
|
||||
8
app/presentation/api/__init__.py
Normal file
8
app/presentation/api/__init__.py
Normal 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)
|
||||
34
app/presentation/api/deps.py
Normal file
34
app/presentation/api/deps.py
Normal 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)]
|
||||
8
app/presentation/api/v1/__init__.py
Normal file
8
app/presentation/api/v1/__init__.py
Normal 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)
|
||||
211
app/presentation/api/v1/posts.py
Normal file
211
app/presentation/api/v1/posts.py
Normal 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__)
|
||||
21
app/presentation/schemas/__init__.py
Normal file
21
app/presentation/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
66
app/presentation/schemas/post.py
Normal file
66
app/presentation/schemas/post.py
Normal 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
|
||||
Reference in New Issue
Block a user