"""Application settings with composition pattern.""" from enum import Enum from functools import cached_property from pydantic import Field, PostgresDsn, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Environment(str, Enum): """Application environment modes.""" DEV = "dev" PROD = "prod" class AppConfig(BaseSettings): """Application configuration.""" name: str = "Blog API" debug: bool = False host: str = "0.0.0.0" port: int = 8000 model_config = SettingsConfigDict( env_prefix="APP_", env_file_encoding="utf-8", case_sensitive=False, ) class DBConfig(BaseSettings): """Database configuration.""" # 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" password: str = "postgres" name: str = "blog" model_config = SettingsConfigDict( env_prefix="DB_", env_file_encoding="utf-8", case_sensitive=False, ) @field_validator("url") @classmethod def validate_url(cls, v: str | None) -> str | None: """Validate database URL if provided.""" if v is None: return v if not any(v.startswith(prefix) for prefix in ("sqlite+", "postgresql+")): raise ValueError("Database URL must start with 'sqlite+' or 'postgresql+'") return v class KCConfig(BaseSettings): """Keycloak configuration.""" server_url: str = "http://localhost:8080" realm: str = "blog" client_id: str = "blog-api" client_secret: str = Field( default="", description="Keycloak client secret - must be set via env in production", ) token_cache_ttl: int = 60 # seconds model_config = SettingsConfigDict( env_prefix="KC_", env_file_encoding="utf-8", case_sensitive=False, ) @property def is_configured(self) -> bool: """Check if Keycloak is properly configured.""" return bool(self.client_secret) class SecurityConfig(BaseSettings): """Security configuration.""" secret_key: str = Field( default="", description="Secret key for JWT - must be set via env in production" ) access_token_expire_minutes: int = 30 model_config = SettingsConfigDict( env_prefix="SECURITY_", env_file_encoding="utf-8", case_sensitive=False, ) @property def is_configured(self) -> bool: """Check if security is properly configured.""" return bool(self.secret_key) class Settings(BaseSettings): """Application configuration settings with composition.""" # 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) security: SecurityConfig = Field(default_factory=SecurityConfig) model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, env_nested_delimiter="__", ) def model_post_init(self, __context: object) -> None: """Validate settings after initialization.""" if self.is_prod: if not self.security.is_configured: raise ValueError("SECURITY_SECRET_KEY must be set in production mode") if not self.kc.is_configured: raise ValueError("KC_CLIENT_SECRET must be set in production mode") @cached_property 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 """ 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", username=self.db.user, password=self.db.password, host=self.db.host, port=self.db.port, path=self.db.name, ) ) # Default dev SQLite URL return "sqlite+aiosqlite:///./blog.db" @property def is_dev(self) -> bool: """Check if running in development mode.""" return self.environment == Environment.DEV @property def is_prod(self) -> bool: """Check if running in production mode.""" return self.environment == Environment.PROD # Global settings instance settings = Settings()