docs: add AI code generation requirements and comprehensive Google-style docstrings

- Add AI code generation requirements to AGENTS.md
- Add module-level docstrings to all 46 Python modules
- Add detailed Google-style docstrings to all classes and functions
- Remove all inline comments following self-documenting code principle
- Include Args, Returns, Raises sections in function docstrings
- Add Attributes and Examples sections to class docstrings
This commit is contained in:
2026-05-02 13:15:21 +03:00
parent 6a528bcbb9
commit ca4e8877a5
52 changed files with 2043 additions and 304 deletions

View File

@@ -1,4 +1,8 @@
"""Infrastructure layer exports."""
"""Infrastructure layer exports.
This module re-exports all infrastructure components including
config, database, repositories, DI, and middleware.
"""
from app.infrastructure.config import Settings, settings
from app.infrastructure.database import (
@@ -15,10 +19,8 @@ from app.infrastructure.middleware import register_exception_handlers
from app.infrastructure.repositories import SQLAlchemyPostRepository
__all__ = [
# Config
"Settings",
"settings",
# Database
"Base",
"PostORM",
"engine",
@@ -26,10 +28,7 @@ __all__ = [
"get_session",
"init_db",
"close_db",
# Repositories
"SQLAlchemyPostRepository",
# DI
"create_container",
# Middleware
"register_exception_handlers",
]

View File

@@ -1,4 +1,8 @@
"""Authentication infrastructure package."""
"""Authentication infrastructure package.
This module provides Keycloak authentication client and models
for token validation and user info retrieval.
"""
from app.infrastructure.auth.client import KeycloakAuthClient
from app.infrastructure.auth.models import KeycloakUser, TokenInfo

View File

@@ -1,4 +1,8 @@
"""Keycloak authentication client."""
"""Keycloak authentication client.
This module provides a client for Keycloak authentication operations
including token introspection and user info retrieval.
"""
import time
@@ -9,10 +13,30 @@ from app.infrastructure.config.settings import Settings
class KeycloakAuthClient:
"""Client for Keycloak authentication operations."""
"""Client for Keycloak authentication operations.
Handles token validation via introspection and user info retrieval.
Implements token caching to reduce Keycloak server load.
Attributes:
_settings: Application settings with Keycloak config.
_base_url: Keycloak realm base URL.
_client_id: OAuth client identifier.
_client_secret: OAuth client secret.
_cache: Token info cache for performance.
_cache_ttl: Cache time-to-live in seconds.
Example:
>>> client = KeycloakAuthClient(settings)
>>> token_info = await client.introspect_token(token)
"""
def __init__(self, settings: Settings) -> None:
"""Initialize Keycloak client with settings."""
"""Initialize Keycloak client with settings.
Args:
settings: Application settings with Keycloak configuration.
"""
self._settings = settings
self._base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}"
self._client_id = settings.kc.client_id
@@ -21,15 +45,30 @@ class KeycloakAuthClient:
self._cache_ttl = settings.kc.token_cache_ttl
def _get_introspection_url(self) -> str:
"""Get token introspection endpoint URL."""
"""Get token introspection endpoint URL.
Returns:
Full URL for token introspection endpoint.
"""
return f"{self._base_url}/protocol/openid-connect/token/introspection"
def _get_userinfo_url(self) -> str:
"""Get userinfo endpoint URL."""
"""Get userinfo endpoint URL.
Returns:
Full URL for userinfo endpoint.
"""
return f"{self._base_url}/protocol/openid-connect/userinfo"
def _get_cached_token(self, token: str) -> TokenInfo | None:
"""Get cached token info if valid."""
"""Get cached token info if valid.
Args:
token: Access token string.
Returns:
Cached TokenInfo if valid and not expired, None otherwise.
"""
if token not in self._cache:
return None
@@ -41,9 +80,13 @@ class KeycloakAuthClient:
return token_info
def _cache_token(self, token: str, token_info: TokenInfo) -> None:
"""Cache token info."""
"""Cache token info.
Args:
token: Access token string as cache key.
token_info: TokenInfo to cache.
"""
self._cache[token] = (token_info, time.time())
# Simple cleanup of old entries
current_time = time.time()
expired_keys = [
k for k, (_, t) in self._cache.items() if current_time - t > self._cache_ttl
@@ -52,13 +95,21 @@ class KeycloakAuthClient:
del self._cache[k]
async def introspect_token(self, token: str) -> TokenInfo:
"""Introspect access token using Keycloak."""
# Check cache first
"""Introspect access token using Keycloak.
Validates token with Keycloak server and extracts user information.
Uses cache to reduce server requests for recently validated tokens.
Args:
token: Access token to validate.
Returns:
TokenInfo with validation result and user claims.
"""
cached = self._get_cached_token(token)
if cached:
return cached
# Prepare introspection request
data = {
"token": token,
"client_id": self._client_id,
@@ -81,7 +132,6 @@ class KeycloakAuthClient:
if not result.get("active", False):
return TokenInfo(active=False, raw_claims=result)
# Extract roles from realm_access or resource_access
roles: list[str] = []
realm_access = result.get("realm_access", {})
if isinstance(realm_access, dict):
@@ -96,13 +146,21 @@ class KeycloakAuthClient:
raw_claims=result,
)
# Cache valid token
self._cache_token(token, token_info)
return token_info
async def get_userinfo(self, token: str) -> KeycloakUser | None:
"""Get user information from Keycloak using access token."""
"""Get user information from Keycloak using access token.
Fetches detailed user profile from Keycloak userinfo endpoint.
Args:
token: Valid access token.
Returns:
KeycloakUser with profile data, or None on error.
"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(

View File

@@ -1,4 +1,8 @@
"""Keycloak authentication models."""
"""Keycloak authentication models.
This module defines data models for Keycloak authentication data
including token info and user profiles.
"""
from dataclasses import dataclass, field
from typing import Any
@@ -6,7 +10,24 @@ from typing import Any
@dataclass(frozen=True)
class TokenInfo:
"""Information about validated token from Keycloak."""
"""Information about validated token from Keycloak.
Contains the result of token introspection including user claims
and role information.
Attributes:
active: Whether the token is active and valid.
user_id: Subject identifier from token.
username: Username from token claims.
email: Email from token claims.
roles: List of roles from token.
raw_claims: Complete raw claims from token.
Example:
>>> token_info = TokenInfo(active=True, user_id="123", roles=["user"])
>>> if token_info.is_valid:
... grant_access()
"""
active: bool
user_id: str = ""
@@ -17,13 +38,32 @@ class TokenInfo:
@property
def is_valid(self) -> bool:
"""Check if token is valid and active."""
"""Check if token is valid and active.
Returns:
True if token is active and has user_id.
"""
return self.active and bool(self.user_id)
@dataclass(frozen=True)
class KeycloakUser:
"""User information from Keycloak."""
"""User information from Keycloak.
Contains user profile data from Keycloak userinfo endpoint.
Attributes:
id: User subject identifier.
username: Username.
email: Email address.
first_name: First name.
last_name: Last name.
roles: List of user roles.
is_active: Whether user account is active.
Example:
>>> user = KeycloakUser(id="123", username="john", email="john@example.com")
"""
id: str
username: str

View File

@@ -1,4 +1,8 @@
"""Infrastructure configuration."""
"""Infrastructure configuration.
This module re-exports all configuration classes and the global
settings instance for application configuration.
"""
from app.infrastructure.config.settings import (
AppConfig,

View File

@@ -1,4 +1,8 @@
"""Application settings with composition pattern."""
"""Application settings with composition pattern.
This module defines the application configuration using pydantic-settings.
Provides typed configuration for database, Keycloak, security, and app settings.
"""
from enum import Enum
from functools import cached_property
@@ -8,14 +12,38 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Environment(str, Enum):
"""Application environment modes."""
"""Application environment modes.
Defines the available deployment environments.
Each environment may have different configuration defaults.
Attributes:
DEV: Development environment with debug features.
PROD: Production environment with strict security.
Example:
>>> if settings.environment == Environment.PROD:
... enable_strict_security()
"""
DEV = "dev"
PROD = "prod"
class AppConfig(BaseSettings):
"""Application configuration."""
"""Application configuration.
Contains general application settings like name, host, and port.
Attributes:
name: Application display name.
debug: Debug mode flag.
host: Server bind host.
port: Server bind port.
Example:
>>> config = AppConfig(name="My API", port=8000)
"""
name: str = "Blog API"
debug: bool = False
@@ -30,14 +58,27 @@ class AppConfig(BaseSettings):
class DBConfig(BaseSettings):
"""Database configuration."""
"""Database configuration.
Contains database connection settings. Supports both SQLite for
development and PostgreSQL for production.
Attributes:
url: Full database URL (optional, can build from components).
echo: Enable SQL query logging.
host: Database server host.
port: Database server port.
user: Database username.
password: Database password.
name: Database name.
Example:
>>> db_config = DBConfig(host="localhost", name="blog")
"""
# For dev: sqlite+aiosqlite:///./blog.db
# For prod: postgresql+asyncpg://user:pass@host:port/db
url: str | None = None
echo: bool = False
# PostgreSQL-specific settings (used in prod)
host: str = "localhost"
port: int = 5432
user: str = "postgres"
@@ -53,7 +94,17 @@ class DBConfig(BaseSettings):
@field_validator("url")
@classmethod
def validate_url(cls, v: str | None) -> str | None:
"""Validate database URL if provided."""
"""Validate database URL if provided.
Args:
v: Database URL string to validate.
Returns:
Validated URL string.
Raises:
ValueError: If URL does not start with supported prefix.
"""
if v is None:
return v
if not any(v.startswith(prefix) for prefix in ("sqlite+", "postgresql+")):
@@ -62,7 +113,20 @@ class DBConfig(BaseSettings):
class KCConfig(BaseSettings):
"""Keycloak configuration."""
"""Keycloak configuration.
Contains Keycloak authentication server settings.
Attributes:
server_url: Keycloak server base URL.
realm: Keycloak realm name.
client_id: OAuth client identifier.
client_secret: OAuth client secret.
token_cache_ttl: Token cache time-to-live in seconds.
Example:
>>> kc = KCConfig(server_url="http://localhost:8080", realm="blog")
"""
server_url: str = "http://localhost:8080"
realm: str = "blog"
@@ -71,7 +135,7 @@ class KCConfig(BaseSettings):
default="",
description="Keycloak client secret - must be set via env in production",
)
token_cache_ttl: int = 60 # seconds
token_cache_ttl: int = 60
model_config = SettingsConfigDict(
env_prefix="KC_",
@@ -81,12 +145,26 @@ class KCConfig(BaseSettings):
@property
def is_configured(self) -> bool:
"""Check if Keycloak is properly configured."""
"""Check if Keycloak is properly configured.
Returns:
True if client_secret is set.
"""
return bool(self.client_secret)
class SecurityConfig(BaseSettings):
"""Security configuration."""
"""Security configuration.
Contains security-related settings for JWT and authentication.
Attributes:
secret_key: Secret key for JWT signing.
access_token_expire_minutes: Token expiration time in minutes.
Example:
>>> security = SecurityConfig(secret_key="super-secret-key")
"""
secret_key: str = Field(
default="", description="Secret key for JWT - must be set via env in production"
@@ -101,17 +179,37 @@ class SecurityConfig(BaseSettings):
@property
def is_configured(self) -> bool:
"""Check if security is properly configured."""
"""Check if security is properly configured.
Returns:
True if secret_key is set.
"""
return bool(self.secret_key)
class Settings(BaseSettings):
"""Application configuration settings with composition."""
"""Application configuration settings with composition.
Main settings class that composes all sub-configurations.
Validates production settings and provides computed properties.
Attributes:
environment: Current deployment environment.
app: Application configuration.
db: Database configuration.
kc: Keycloak configuration.
security: Security configuration.
Raises:
ValueError: If required production settings are missing.
Example:
>>> settings = Settings()
>>> print(settings.database_url)
"""
# Environment mode
environment: Environment = Environment.DEV
# Sub-configurations
app: AppConfig = Field(default_factory=AppConfig)
db: DBConfig = Field(default_factory=DBConfig)
kc: KCConfig = Field(default_factory=KCConfig)
@@ -125,7 +223,13 @@ class Settings(BaseSettings):
)
def model_post_init(self, __context: object) -> None:
"""Validate settings after initialization."""
"""Validate settings after initialization.
Checks that required settings are configured for production mode.
Raises:
ValueError: If required production settings are missing.
"""
if self.is_prod:
if not self.security.is_configured:
raise ValueError("SECURITY_SECRET_KEY must be set in production mode")
@@ -136,14 +240,16 @@ class Settings(BaseSettings):
def database_url(self) -> str:
"""Get database URL based on environment.
- In dev: uses SQLite if no URL provided
- In prod: uses PostgreSQL if no URL provided
Returns configured URL or builds one from components.
Uses SQLite for development, PostgreSQL for production.
Returns:
Complete database URL string.
"""
if self.db.url:
return self.db.url
if self.environment == Environment.PROD:
# Build PostgreSQL URL from components
return str(
PostgresDsn.build(
scheme="postgresql+asyncpg",
@@ -155,19 +261,25 @@ class Settings(BaseSettings):
)
)
# Default dev SQLite URL
return "sqlite+aiosqlite:///./blog.db"
@property
def is_dev(self) -> bool:
"""Check if running in development mode."""
"""Check if running in development mode.
Returns:
True if environment is DEV.
"""
return self.environment == Environment.DEV
@property
def is_prod(self) -> bool:
"""Check if running in production mode."""
"""Check if running in production mode.
Returns:
True if environment is PROD.
"""
return self.environment == Environment.PROD
# Global settings instance
settings = Settings()

View File

@@ -1,4 +1,8 @@
"""Database infrastructure."""
"""Database infrastructure.
This module re-exports database connection utilities and ORM models
for data persistence.
"""
from app.infrastructure.database.connection import (
AsyncSessionLocal,

View File

@@ -1,4 +1,8 @@
"""Database connection and session management."""
"""Database connection and session management.
This module handles database engine creation, session management,
and connection lifecycle for the application.
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
@@ -13,22 +17,26 @@ from sqlalchemy.ext.asyncio import (
from app.infrastructure.config import settings
# Convert SQLite URL to async format if needed
def _get_database_url() -> str:
"""Get database URL with SQLite async compatibility.
Converts SQLite URL to async format if needed.
Returns:
Database URL string ready for async engine.
"""
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.db.echo,
future=True,
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
@@ -39,7 +47,11 @@ AsyncSessionLocal = async_sessionmaker(
async def get_session() -> AsyncGenerator[AsyncSession]:
"""Get database session."""
"""Get database session.
Yields:
AsyncSession instance for database operations.
"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -49,7 +61,11 @@ async def get_session() -> AsyncGenerator[AsyncSession]:
@asynccontextmanager
async def get_session_context() -> AsyncGenerator[AsyncSession]:
"""Get database session as context manager."""
"""Get database session as context manager.
Yields:
AsyncSession instance for database operations.
"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -58,7 +74,11 @@ async def get_session_context() -> AsyncGenerator[AsyncSession]:
async def init_db() -> None:
"""Initialize database tables."""
"""Initialize database tables.
Creates all tables defined in the metadata.
Should be called on application startup.
"""
from app.infrastructure.database.models import Base
async with engine.begin() as conn:
@@ -66,5 +86,9 @@ async def init_db() -> None:
async def close_db() -> None:
"""Close database connections."""
"""Close database connections.
Disposes of the engine and all connections.
Should be called on application shutdown.
"""
await engine.dispose()

View File

@@ -1,4 +1,8 @@
"""SQLAlchemy ORM models."""
"""SQLAlchemy ORM models.
This module defines the database ORM models that map to database tables.
Models are used by repositories for data persistence.
"""
from datetime import UTC, datetime
from uuid import uuid4
@@ -10,7 +14,25 @@ Base = declarative_base()
class PostORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for Blog Post."""
"""SQLAlchemy model for Blog Post.
Database table representation of blog posts.
Maps to the 'posts' table with all post attributes.
Attributes:
id: Primary key as UUID string.
title: Post title (max 200 chars).
content: Post content (text).
slug: URL-friendly unique identifier.
author_id: Author reference.
published: Publication status flag.
tags: JSON array of tags.
created_at: Creation timestamp.
updated_at: Last update timestamp.
Example:
>>> post = PostORM(title="Post", content="...", slug="post", author_id="user-1")
"""
__tablename__ = "posts"

View File

@@ -1,4 +1,8 @@
"""Dependency Injection using Dishka."""
"""Dependency Injection using Dishka.
This module provides DI container setup and configuration
for the application using Dishka library.
"""
from app.infrastructure.di.container import create_container

View File

@@ -1,4 +1,8 @@
"""Dishka providers for dependency injection."""
"""Dishka providers for dependency injection.
This module defines Dishka providers for all application dependencies.
Providers configure how dependencies are created and scoped.
"""
from collections.abc import AsyncGenerator
@@ -22,16 +26,31 @@ from app.infrastructure.repositories.post import SQLAlchemyPostRepository
class DatabaseProvider(Provider):
"""Provider for database-related dependencies."""
"""Provider for database-related dependencies.
Provides database engine and session scoped appropriately.
Engine is application-scoped, sessions are request-scoped.
Example:
>>> provider = DatabaseProvider()
"""
@provide(scope=Scope.APP)
def get_engine(self) -> AsyncEngine:
"""Provide SQLAlchemy engine."""
"""Provide SQLAlchemy engine.
Returns:
AsyncEngine instance for database operations.
"""
return engine
@provide(scope=Scope.REQUEST)
async def get_session(self) -> AsyncGenerator[AsyncSession]:
"""Provide database session per request."""
"""Provide database session per request.
Yields:
AsyncSession instance for the request lifetime.
"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -40,27 +59,62 @@ class DatabaseProvider(Provider):
class RepositoryProvider(Provider):
"""Provider for repository implementations."""
"""Provider for repository implementations.
Provides concrete repository implementations for interfaces.
All repositories are request-scoped.
Example:
>>> provider = RepositoryProvider()
"""
@provide(scope=Scope.REQUEST)
def get_post_repository(self, session: AsyncSession) -> PostRepository:
"""Provide PostRepository implementation."""
"""Provide PostRepository implementation.
Args:
session: Database session from DI container.
Returns:
SQLAlchemyPostRepository instance.
"""
return SQLAlchemyPostRepository(session)
class TransactionManagerProvider(Provider):
"""Provider for transaction manager."""
"""Provider for transaction manager.
Provides transaction manager implementation for use cases.
Scoped per request for transaction isolation.
Example:
>>> provider = TransactionManagerProvider()
"""
@provide(scope=Scope.REQUEST)
def get_transaction_manager(self, session: AsyncSession) -> TransactionManager:
"""Provide TransactionManager implementation."""
"""Provide TransactionManager implementation.
Args:
session: Database session from DI container.
Returns:
SessionTransactionManager instance.
"""
from app.infrastructure.di.transaction_manager import SessionTransactionManager
return SessionTransactionManager(session)
class UseCaseProvider(Provider):
"""Provider for use cases."""
"""Provider for use cases.
Provides all application use cases with their dependencies.
All use cases are request-scoped for transaction isolation.
Example:
>>> provider = UseCaseProvider()
"""
@provide(scope=Scope.REQUEST)
def get_create_post_use_case(
@@ -68,7 +122,15 @@ class UseCaseProvider(Provider):
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> CreatePostUseCase:
"""Provide CreatePostUseCase."""
"""Provide CreatePostUseCase.
Args:
post_repo: Post repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured CreatePostUseCase instance.
"""
return CreatePostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
@@ -80,7 +142,15 @@ class UseCaseProvider(Provider):
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> GetPostUseCase:
"""Provide GetPostUseCase."""
"""Provide GetPostUseCase.
Args:
post_repo: Post repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured GetPostUseCase instance.
"""
return GetPostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
@@ -92,7 +162,15 @@ class UseCaseProvider(Provider):
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> UpdatePostUseCase:
"""Provide UpdatePostUseCase."""
"""Provide UpdatePostUseCase.
Args:
post_repo: Post repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured UpdatePostUseCase instance.
"""
return UpdatePostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
@@ -104,7 +182,15 @@ class UseCaseProvider(Provider):
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> DeletePostUseCase:
"""Provide DeletePostUseCase."""
"""Provide DeletePostUseCase.
Args:
post_repo: Post repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured DeletePostUseCase instance.
"""
return DeletePostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
@@ -116,7 +202,15 @@ class UseCaseProvider(Provider):
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> ListPostsUseCase:
"""Provide ListPostsUseCase."""
"""Provide ListPostsUseCase.
Args:
post_repo: Post repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured ListPostsUseCase instance.
"""
return ListPostsUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
@@ -128,7 +222,15 @@ class UseCaseProvider(Provider):
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> PublishPostUseCase:
"""Provide PublishPostUseCase."""
"""Provide PublishPostUseCase.
Args:
post_repo: Post repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured PublishPostUseCase instance.
"""
return PublishPostUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
@@ -136,9 +238,20 @@ class UseCaseProvider(Provider):
class KeycloakProvider(Provider):
"""Provider for Keycloak authentication client."""
"""Provider for Keycloak authentication client.
Provides Keycloak client as application-scoped singleton.
Client is stateless and can be shared across requests.
Example:
>>> provider = KeycloakProvider()
"""
@provide(scope=Scope.APP)
def get_keycloak_client(self) -> KeycloakAuthClient:
"""Provide KeycloakAuthClient singleton."""
"""Provide KeycloakAuthClient singleton.
Returns:
KeycloakAuthClient instance.
"""
return KeycloakAuthClient(settings)

View File

@@ -1,4 +1,8 @@
"""SQLAlchemy implementation of Transaction Manager."""
"""SQLAlchemy implementation of Transaction Manager.
This module provides the concrete implementation of TransactionManager
using SQLAlchemy async sessions for transaction control.
"""
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,19 +10,44 @@ from app.application.interfaces import TransactionManager
class SessionTransactionManager(TransactionManager):
"""SQLAlchemy Session-based Transaction Manager."""
"""SQLAlchemy Session-based Transaction Manager.
Implements transaction control using SQLAlchemy async session.
Tracks commit state to prevent duplicate commits.
Attributes:
_session: SQLAlchemy async session for transactions.
_committed: Flag indicating if transaction was committed.
Example:
>>> tx_manager = SessionTransactionManager(session)
>>> await tx_manager.commit()
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize transaction manager.
Args:
session: SQLAlchemy async session instance.
"""
self._session = session
self._committed: bool = False
async def commit(self) -> None:
"""Commit the current transaction."""
"""Commit the current transaction.
Persists all pending changes to the database.
Only commits once - subsequent calls are no-ops.
"""
if not self._committed:
await self._session.commit()
self._committed = True
async def rollback(self) -> None:
"""Rollback the current transaction."""
"""Rollback the current transaction.
Discards all pending changes.
Only rolls back if not already committed.
"""
if not self._committed:
await self._session.rollback()

View File

@@ -1,4 +1,8 @@
"""Infrastructure middleware."""
"""Infrastructure middleware.
This module re-exports exception handling middleware for
centralized error management in the application.
"""
from app.infrastructure.middleware.error_handler import (
domain_exception_handler,

View File

@@ -1,4 +1,8 @@
"""Exception handling middleware."""
"""Exception handling middleware.
This module provides exception handlers for FastAPI application.
Maps domain exceptions to appropriate HTTP status codes.
"""
from datetime import UTC, datetime
@@ -17,7 +21,14 @@ from app.domain.exceptions import (
def get_status_code(exc: DomainException) -> int:
"""Map domain exceptions to HTTP status codes."""
"""Map domain exceptions to HTTP status codes.
Args:
exc: Domain exception instance.
Returns:
HTTP status code for the exception type.
"""
match exc:
case ValidationException():
return 400
@@ -34,7 +45,17 @@ def get_status_code(exc: DomainException) -> int:
async def domain_exception_handler(request: Request, exc: DomainException) -> JSONResponse:
"""Handle domain exceptions."""
"""Handle domain exceptions.
Converts domain exceptions to JSON error responses.
Args:
request: FastAPI request object.
exc: Domain exception instance.
Returns:
JSONResponse with error details.
"""
status_code = get_status_code(exc)
return JSONResponse(
status_code=status_code,
@@ -48,7 +69,17 @@ async def domain_exception_handler(request: Request, exc: DomainException) -> JS
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
"""Handle HTTP exceptions."""
"""Handle HTTP exceptions.
Converts Starlette HTTP exceptions to JSON error responses.
Args:
request: FastAPI request object.
exc: Starlette HTTP exception instance.
Returns:
JSONResponse with error details.
"""
return JSONResponse(
status_code=exc.status_code,
content={
@@ -61,7 +92,18 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle generic exceptions."""
"""Handle generic exceptions.
Converts unhandled exceptions to generic error responses.
Hides internal details for security.
Args:
request: FastAPI request object.
exc: Generic exception instance.
Returns:
JSONResponse with generic error message.
"""
return JSONResponse(
status_code=500,
content={
@@ -74,16 +116,16 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes
def register_exception_handlers(app: FastAPI) -> None:
"""Register all exception handlers with FastAPI app."""
"""Register all exception handlers with FastAPI app.
Args:
app: FastAPI application instance.
Raises:
TypeError: If app is not a FastAPI instance.
"""
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

@@ -1,4 +1,8 @@
"""Repository implementations."""
"""Repository implementations.
This module re-exports concrete repository implementations
for data access using SQLAlchemy ORM.
"""
from app.infrastructure.repositories.post import SQLAlchemyPostRepository

View File

@@ -1,4 +1,8 @@
"""SQLAlchemy implementation of PostRepository."""
"""SQLAlchemy implementation of PostRepository.
This module provides the concrete implementation of PostRepository
using SQLAlchemy ORM for data persistence.
"""
from uuid import UUID
@@ -12,13 +16,36 @@ from app.infrastructure.database.models import PostORM
class SQLAlchemyPostRepository(PostRepository):
"""SQLAlchemy implementation of Post repository."""
"""SQLAlchemy implementation of Post repository.
Provides data access methods for Post entities using SQLAlchemy ORM.
Handles conversion between domain entities and ORM models.
Attributes:
_session: SQLAlchemy async session for database operations.
Example:
>>> repo = SQLAlchemyPostRepository(session)
>>> post = await repo.get_by_id(post_id)
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize repository with session.
Args:
session: SQLAlchemy async session instance.
"""
self._session = session
def _to_domain(self, orm: PostORM) -> Post:
"""Convert ORM model to domain entity."""
"""Convert ORM model to domain entity.
Args:
orm: SQLAlchemy ORM model instance.
Returns:
Domain Post entity with validated value objects.
"""
return Post(
id=UUID(orm.id),
title=Title(orm.title),
@@ -32,7 +59,14 @@ class SQLAlchemyPostRepository(PostRepository):
)
def _to_orm(self, post: Post) -> PostORM:
"""Convert domain entity to ORM model."""
"""Convert domain entity to ORM model.
Args:
post: Domain Post entity.
Returns:
SQLAlchemy ORM model instance.
"""
return PostORM(
id=str(post.id),
title=post.title.value,
@@ -46,25 +80,43 @@ class SQLAlchemyPostRepository(PostRepository):
)
async def get_by_id(self, entity_id: UUID) -> Post | None:
"""Get post by ID."""
"""Get post by ID.
Args:
entity_id: Unique identifier of the post.
Returns:
Post entity if found, None otherwise.
"""
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."""
"""Get all posts.
Returns:
List of all Post entities.
"""
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."""
"""Add new post.
Args:
entity: Post entity to add.
"""
orm = self._to_orm(entity)
self._session.add(orm)
# Commit делает TransactionManager
async def update(self, entity: Post) -> None:
"""Update existing post."""
"""Update existing post.
Args:
entity: Post entity with updated data.
"""
result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity.id)))
orm = result.scalar_one()
@@ -75,22 +127,38 @@ class SQLAlchemyPostRepository(PostRepository):
orm.tags = entity.tags
orm.updated_at = entity.updated_at
# Commit делает TransactionManager
async def delete(self, entity_id: UUID) -> None:
"""Delete post by ID."""
"""Delete post by ID.
Args:
entity_id: Unique identifier of the post to delete.
"""
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."""
"""Check if post exists.
Args:
entity_id: Unique identifier of the post.
Returns:
True if post exists, False otherwise.
"""
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."""
"""Get post by slug.
Args:
slug: URL-friendly slug identifier.
Returns:
Post entity if found, None otherwise.
"""
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
@@ -101,7 +169,16 @@ class SQLAlchemyPostRepository(PostRepository):
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Get posts by author."""
"""Get posts by author.
Args:
author_id: Identifier of the author.
limit: Maximum number of posts to return.
offset: Number of posts to skip.
Returns:
List of Post entities by the author.
"""
query = select(PostORM).where(PostORM.author_id == author_id)
if limit is not None:
query = query.limit(limit)
@@ -116,7 +193,15 @@ class SQLAlchemyPostRepository(PostRepository):
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Get published posts."""
"""Get published posts.
Args:
limit: Maximum number of posts to return.
offset: Number of posts to skip.
Returns:
List of published Post entities.
"""
query = select(PostORM).where(PostORM.published.is_(True))
if limit is not None:
query = query.limit(limit)
@@ -132,7 +217,16 @@ class SQLAlchemyPostRepository(PostRepository):
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Get posts by tag."""
"""Get posts by tag.
Args:
tag: Tag to filter by.
limit: Maximum number of posts to return.
offset: Number of posts to skip.
Returns:
List of Post entities with the tag.
"""
query = select(PostORM).where(PostORM.tags.contains([tag]))
if limit is not None:
query = query.limit(limit)
@@ -143,7 +237,14 @@ class SQLAlchemyPostRepository(PostRepository):
return [self._to_domain(orm) for orm in orms]
async def slug_exists(self, slug: str) -> bool:
"""Check if slug exists."""
"""Check if slug exists.
Args:
slug: Slug to check for existence.
Returns:
True if slug exists, False otherwise.
"""
result = await self._session.execute(select(PostORM).where(PostORM.slug == slug))
return result.scalar_one_or_none() is not None
@@ -153,7 +254,16 @@ class SQLAlchemyPostRepository(PostRepository):
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Search posts."""
"""Search posts.
Args:
query: Search query string.
limit: Maximum number of posts to return.
offset: Number of posts to skip.
Returns:
List of Post entities matching the query.
"""
search_pattern = f"%{query}%"
stmt = select(PostORM).where(
or_(