diff --git a/README.md b/README.md index e9e0525..e528f4a 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ natural. │ └── ... # ports, enums, exceptions, etc. │ ├── application/... # application layer - │ ├── commands/ # write operations, business-critical reads + │ ├── commands/ # write ops, business-critical reads │ │ ├── create_user.py # interactor │ │ └── ... # other interactors │ ├── queries/ # optimized read operations @@ -444,9 +444,9 @@ natural. │ └── ... # ports, exceptions, etc. │ ├── infrastructure/... # infrastructure layer - │ ├── auth_session/... # auth context (session-based) - │ ├── handlers/... # account handlers (log in, log out, sign up) - │ └── ... # adapters, persistence, exceptions, etc. + │ ├── adapters/... # port adapters + │ ├── auth/... # auth context (session-based) + │ └── ... # persistence, exceptions, etc. │ ├── presentation/... # presentation layer │ └── http/ # http interface diff --git a/config/toml_config_manager.py b/config/toml_config_manager.py index 94ecb8c..356b032 100644 --- a/config/toml_config_manager.py +++ b/config/toml_config_manager.py @@ -9,6 +9,9 @@ import rtoml +ConfigDict = dict[str, Any] +ExportEnv = dict[str, str] + log = logging.getLogger(__name__) @@ -55,6 +58,9 @@ def configure_logging(*, level: LoggingLevel = DEFAULT_LOG_LEVEL) -> None: # ENVIRONMENT & PATHS +ENV_VAR_NAME: Final[str] = "APP_ENV" + + class ValidEnvs(StrEnum): """ Values should reflect actual directory names. @@ -76,9 +82,7 @@ class DirContents(StrEnum): DOTENV_NAME = ".env" -ENV_VAR_NAME: Final[str] = "APP_ENV" - -BASE_DIR_PATH: Final[Path] = Path(__file__).resolve().parent.parent +BASE_DIR_PATH: Final[Path] = Path(__file__).resolve().parents[1] CONFIG_PATH: Final[Path] = BASE_DIR_PATH / "config" ENV_TO_DIR_PATHS: Final[Mapping[ValidEnvs, Path]] = MappingProxyType({ @@ -88,7 +92,7 @@ class DirContents(StrEnum): }) -def validate_env(*, env: str | None) -> ValidEnvs: +def validate_env(env: str | None) -> ValidEnvs: if env is None: raise ValueError(f"{ENV_VAR_NAME} is not set.") try: @@ -101,19 +105,33 @@ def validate_env(*, env: str | None) -> ValidEnvs: def get_current_env() -> ValidEnvs: - env_value = os.getenv(ENV_VAR_NAME) - return validate_env(env=env_value) + return validate_env(os.getenv(ENV_VAR_NAME)) # CONFIG READING +def load_full_config( + env: ValidEnvs, + dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS, + main_config: DirContents = DirContents.CONFIG_NAME, + secrets_config: DirContents = DirContents.SECRETS_NAME, +) -> ConfigDict: + log.info("Reading config for environment: '%s'", env) + config = read_config(env=env, config=main_config, dir_paths=dir_paths) + try: + secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths) + except FileNotFoundError: + log.warning("Secrets file not found. Full config will not contain secrets.") + return config + return merge_dicts(dict1=config, dict2=secrets) + + def read_config( - *, env: ValidEnvs, - config: DirContents, dir_paths: Mapping[ValidEnvs, Path], -) -> dict[str, Any]: + config: DirContents, +) -> ConfigDict: dir_path = dir_paths.get(env) if dir_path is None: raise FileNotFoundError(f"No directory path configured for environment: {env}") @@ -126,7 +144,7 @@ def read_config( return rtoml.load(file) -def merge_dicts(*, dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]: +def merge_dicts(*, dict1: ConfigDict, dict2: ConfigDict) -> ConfigDict: result = dict1.copy() for key, value in dict2.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): @@ -136,31 +154,68 @@ def merge_dicts(*, dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, An return result -def load_full_config( - *, +# EXPORT PROCESSING + + +EXPORT_SECTION: Final[str] = "export" +EXPORT_FIELDS_KEY: Final[str] = "fields" + + +def get_exported_env_variables( env: ValidEnvs, - main_config: DirContents = DirContents.CONFIG_NAME, - secrets_config: DirContents = DirContents.SECRETS_NAME, dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS, -) -> dict[str, Any]: - log.info("Reading config for environment: '%s'", env) - config = read_config(env=env, config=main_config, dir_paths=dir_paths) - try: - secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths) - except FileNotFoundError: - log.warning("Secrets file not found. Full config will not contain secrets.") - return config - return merge_dicts(dict1=config, dict2=secrets) +) -> ExportEnv: + config = load_full_config(env=env, dir_paths=dir_paths) + export_fields = load_export_fields(env=env, dir_paths=dir_paths) + return extract_export_fields_from_config(config=config, export_fields=export_fields) -# EXPORT PROCESSING +def load_export_fields( + env: ValidEnvs, + dir_paths: Mapping[ValidEnvs, Path], +) -> list[str]: + export_data = read_config( + env=env, + config=DirContents.EXPORT_NAME, + dir_paths=dir_paths, + ) + + export_section = export_data.get(EXPORT_SECTION) + if not isinstance(export_section, dict): + raise ValueError( + f"Invalid {DirContents.EXPORT_NAME}: missing [{EXPORT_SECTION}] section" + ) + + fields = export_section.get(EXPORT_FIELDS_KEY) + if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields): + raise ValueError( + f"Invalid {DirContents.EXPORT_NAME}: " + f"'{EXPORT_FIELDS_KEY}' must be a list of strings" + ) + if not fields: + raise ValueError( + f"Invalid {DirContents.EXPORT_NAME}: '{EXPORT_FIELDS_KEY}' cannot be empty" + ) + return fields -def get_env_value_by_export_field(*, config: dict[str, Any], field: str) -> Any: - parts = field.split(".") + +def extract_export_fields_from_config( + config: ConfigDict, + export_fields: list[str], +) -> ExportEnv: + result: ExportEnv = {} + for field in export_fields: + str_value = get_env_value_by_export_field(config=config, field=field) + env_key = "_".join(part.upper() for part in field.split(".")) + result[env_key] = str_value + return result + + +def get_env_value_by_export_field(*, config: ConfigDict, field: str) -> str: current = config - for part in parts: - if part not in current: + for part in field.split("."): + if not isinstance(current, dict) or part not in current: raise KeyError(f"Field '{field}' not found in config") current = current[part] @@ -169,85 +224,59 @@ def get_env_value_by_export_field(*, config: dict[str, Any], field: str) -> Any: f"Field '{field}' cannot be converted to string: " f"got {type(current).__name__}", ) + try: return str(current) except (TypeError, ValueError) as e: raise ValueError(f"Field '{field}' cannot be converted to string: {e!s}") from e -def extract_exported( - *, - config: dict[str, Any], - export_fields: list[str], -) -> dict[str, str]: - result: dict[str, str] = {} - for field in export_fields: - str_value = get_env_value_by_export_field(config=config, field=field) - env_key = "_".join(part.upper() for part in field.split(".")) - result[env_key] = str_value - return result - - -def load_export_fields(*, env: ValidEnvs) -> tuple[dict[str, Any], list[str]]: - config = load_full_config(env=env) - export_data = read_config( - env=env, - config=DirContents.EXPORT_NAME, - dir_paths=ENV_TO_DIR_PATHS, - ) - if "export" not in export_data or "fields" not in export_data["export"]: - raise ValueError("Invalid export.toml: missing [export] section or 'fields'") - export_fields = export_data["export"]["fields"] - return config, export_fields +# DOTENV GENERATION -# DOTENV GENERATION +def write_dotenv_file( + *, + env: ValidEnvs, + exported_fields: ExportEnv, + generated_at: datetime | None = None, +) -> None: + if generated_at is None: + generated_at = datetime.now(UTC) + dotenv_filename = f"{DirContents.DOTENV_NAME}.{env.value}" + dotenv_path = ENV_TO_DIR_PATHS[env] / dotenv_filename -def write_dotenv_file(*, env: ValidEnvs, exported_fields: dict[str, str]) -> None: - env_filename = f"{DirContents.DOTENV_NAME}.{env.value}" - env_path = ENV_TO_DIR_PATHS[env] / env_filename header = [ "# This .env file was automatically generated by toml_config_manager.", "# Do not edit directly. Make changes in config.toml or .secrets.toml instead.", "# Ensure values here match those in config files.", f"# Environment: {env}", - f"# Generated: {datetime.now(UTC).isoformat()}", + f"# Generated: {generated_at.isoformat()}", ] body = [f"{key}={value}" for key, value in exported_fields.items()] body.append("") - with open(env_path, "w", encoding="utf-8") as f: + with open(dotenv_path, "w", encoding="utf-8") as f: f.write("\n".join(header + body)) - try: - relative_path = env_path.relative_to(BASE_DIR_PATH) - except ValueError: - relative_path = env_path - log.info( "Dotenv for environment '%s' was successfully generated at '%s'! ✨", env.value, - relative_path, + str(dotenv_path.resolve()), ) -def generate_dotenv(*, env: ValidEnvs) -> None: - config, export_fields = load_export_fields(env=env) - exported_fields = extract_exported(config=config, export_fields=export_fields) - write_dotenv_file(env=env, exported_fields=exported_fields) - - # ENTRY POINT def main() -> None: - log_lvl: str = os.getenv(LOG_LEVEL_VAR_NAME, DEFAULT_LOG_LEVEL) - validated_log_lvl: LoggingLevel = validate_logging_level(level=log_lvl) - configure_logging(level=validated_log_lvl) + log_lvl_str = os.getenv(LOG_LEVEL_VAR_NAME, DEFAULT_LOG_LEVEL) + log_lvl = validate_logging_level(level=log_lvl_str) + configure_logging(level=log_lvl) - current_env = get_current_env() - generate_dotenv(env=current_env) + env = get_current_env() + exported_fields = get_exported_env_variables(env) + write_dotenv_file(env=env, exported_fields=exported_fields) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 5118453..92effd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,7 +134,7 @@ ignore = [ # "src/app/domain/value_objects/base.py" = ["B024", ] # abstract-base-class-without-abstract-method "src/app/infrastructure/adapters/password_hasher_bcrypt.py" = ["E501"] # line-too-long -"src/app/infrastructure/constants.py" = ["S105"] # hardcoded-password-string +"src/app/infrastructure/auth/session/constants.py" = ["S105"] # hardcoded-password-string "src/app/presentation/http/auth/constants.py" = ["S105"] # hardcoded-password-string "src/app/presentation/http/exceptions/handlers.py" = ["RUF029", ] # unused-async "scripts/dishka/plot_dependencies_data.py" = ["T201", ] # print diff --git a/scripts/dishka/plot_dependencies_data.py b/scripts/dishka/plot_dependencies_data.py index b821f21..83d6d3f 100644 --- a/scripts/dishka/plot_dependencies_data.py +++ b/scripts/dishka/plot_dependencies_data.py @@ -3,7 +3,7 @@ from dishka import AsyncContainer, make_async_container from app.setup.config.settings import AppSettings, load_settings -from app.setup.ioc.registry import get_providers +from app.setup.ioc.provider_registry import get_providers def make_plot_data_container(settings: AppSettings) -> AsyncContainer: diff --git a/src/app/application/common/services/authorization/authorize.py b/src/app/application/common/services/authorization/authorize.py index 3d93ac5..f75c3a4 100644 --- a/src/app/application/common/services/authorization/authorize.py +++ b/src/app/application/common/services/authorization/authorize.py @@ -1,9 +1,9 @@ -from app.application.common.constants import AUTHZ_NOT_AUTHORIZED from app.application.common.exceptions.authorization import AuthorizationError from app.application.common.services.authorization.base import ( Permission, PermissionContext, ) +from app.application.common.services.constants import AUTHZ_NOT_AUTHORIZED def authorize[PC: PermissionContext]( diff --git a/src/app/application/common/constants.py b/src/app/application/common/services/constants.py similarity index 100% rename from src/app/application/common/constants.py rename to src/app/application/common/services/constants.py diff --git a/src/app/application/common/services/current_user.py b/src/app/application/common/services/current_user.py index ebfb29d..c4ba0c0 100644 --- a/src/app/application/common/services/current_user.py +++ b/src/app/application/common/services/current_user.py @@ -1,10 +1,13 @@ import logging -from app.application.common.constants import AUTHZ_NO_CURRENT_USER, AUTHZ_NOT_AUTHORIZED from app.application.common.exceptions.authorization import AuthorizationError from app.application.common.ports.access_revoker import AccessRevoker from app.application.common.ports.identity_provider import IdentityProvider from app.application.common.ports.user_command_gateway import UserCommandGateway +from app.application.common.services.constants import ( + AUTHZ_NO_CURRENT_USER, + AUTHZ_NOT_AUTHORIZED, +) from app.domain.entities.user import User log = logging.getLogger(__name__) diff --git a/src/app/infrastructure/adapters/constants.py b/src/app/infrastructure/adapters/constants.py new file mode 100644 index 0000000..6f355f2 --- /dev/null +++ b/src/app/infrastructure/adapters/constants.py @@ -0,0 +1,8 @@ +from typing import Final + +DB_CONSTRAINT_VIOLATION: Final[str] = "Database constraint violation." +DB_COMMIT_DONE: Final[str] = "Commit was done." +DB_COMMIT_FAILED: Final[str] = "Commit failed." +DB_FLUSH_DONE: Final[str] = "Flush was done." +DB_FLUSH_FAILED: Final[str] = "Flush failed." +DB_QUERY_FAILED: Final[str] = "Database query failed." diff --git a/src/app/infrastructure/adapters/main_transaction_manager_sqla.py b/src/app/infrastructure/adapters/main_transaction_manager_sqla.py index a2ce84a..fddec11 100644 --- a/src/app/infrastructure/adapters/main_transaction_manager_sqla.py +++ b/src/app/infrastructure/adapters/main_transaction_manager_sqla.py @@ -8,8 +8,7 @@ TransactionManager, ) from app.domain.exceptions.user import UsernameAlreadyExistsError -from app.infrastructure.adapters.types import MainAsyncSession -from app.infrastructure.constants import ( +from app.infrastructure.adapters.constants import ( DB_COMMIT_DONE, DB_COMMIT_FAILED, DB_CONSTRAINT_VIOLATION, @@ -17,6 +16,7 @@ DB_FLUSH_FAILED, DB_QUERY_FAILED, ) +from app.infrastructure.adapters.types import MainAsyncSession from app.infrastructure.exceptions.gateway import DataMapperError log = logging.getLogger(__name__) diff --git a/src/app/infrastructure/adapters/password_hasher_bcrypt.py b/src/app/infrastructure/adapters/password_hasher_bcrypt.py index f656400..ee4480d 100644 --- a/src/app/infrastructure/adapters/password_hasher_bcrypt.py +++ b/src/app/infrastructure/adapters/password_hasher_bcrypt.py @@ -24,6 +24,7 @@ def hash(self, raw_password: RawPassword) -> bytes: This issue can be resolved by applying `base64` encoding to the digest. The resulting `base64(hmac-sha256(password, pepper))` string is then ready for bcrypt hashing. Salt is added to this string before passing it to `bcrypt` for the final hashing step. + Inspired by: https://blog.ircmaxell.com/2015/03/security-issue-combining-bcrypt-with.html """ base64_hmac_password: bytes = self._add_pepper(raw_password, self._pepper) salt: bytes = bcrypt.gensalt() diff --git a/src/app/infrastructure/adapters/user_data_mapper_sqla.py b/src/app/infrastructure/adapters/user_data_mapper_sqla.py index 1bc6d67..4f9b445 100644 --- a/src/app/infrastructure/adapters/user_data_mapper_sqla.py +++ b/src/app/infrastructure/adapters/user_data_mapper_sqla.py @@ -5,8 +5,8 @@ from app.domain.entities.user import User from app.domain.value_objects.user_id import UserId from app.domain.value_objects.username.username import Username +from app.infrastructure.adapters.constants import DB_QUERY_FAILED from app.infrastructure.adapters.types import MainAsyncSession -from app.infrastructure.constants import DB_QUERY_FAILED from app.infrastructure.exceptions.gateway import DataMapperError diff --git a/src/app/infrastructure/adapters/user_reader_sqla.py b/src/app/infrastructure/adapters/user_reader_sqla.py index ea999d6..fc350bd 100644 --- a/src/app/infrastructure/adapters/user_reader_sqla.py +++ b/src/app/infrastructure/adapters/user_reader_sqla.py @@ -10,8 +10,8 @@ from app.application.common.query_params.sorting import SortingOrder from app.application.common.query_params.user import UserListParams from app.domain.enums.user_role import UserRole +from app.infrastructure.adapters.constants import DB_QUERY_FAILED from app.infrastructure.adapters.types import MainAsyncSession -from app.infrastructure.constants import DB_QUERY_FAILED from app.infrastructure.exceptions.gateway import ReaderError from app.infrastructure.persistence_sqla.mappings.user import users_table diff --git a/src/app/infrastructure/auth_session/__init__.py b/src/app/infrastructure/auth/__init__.py similarity index 100% rename from src/app/infrastructure/auth_session/__init__.py rename to src/app/infrastructure/auth/__init__.py diff --git a/src/app/infrastructure/auth_session/adapters/__init__.py b/src/app/infrastructure/auth/adapters/__init__.py similarity index 100% rename from src/app/infrastructure/auth_session/adapters/__init__.py rename to src/app/infrastructure/auth/adapters/__init__.py diff --git a/src/app/infrastructure/auth_session/adapters/access_revoker.py b/src/app/infrastructure/auth/adapters/access_revoker.py similarity index 89% rename from src/app/infrastructure/auth_session/adapters/access_revoker.py rename to src/app/infrastructure/auth/adapters/access_revoker.py index 4a873da..c52b8f3 100644 --- a/src/app/infrastructure/auth_session/adapters/access_revoker.py +++ b/src/app/infrastructure/auth/adapters/access_revoker.py @@ -1,6 +1,6 @@ from app.application.common.ports.access_revoker import AccessRevoker from app.domain.value_objects.user_id import UserId -from app.infrastructure.auth_session.service import AuthSessionService +from app.infrastructure.auth.session.service import AuthSessionService class AuthSessionAccessRevoker(AccessRevoker): diff --git a/src/app/infrastructure/auth_session/adapters/data_mapper_sqla.py b/src/app/infrastructure/auth/adapters/data_mapper_sqla.py similarity index 89% rename from src/app/infrastructure/auth_session/adapters/data_mapper_sqla.py rename to src/app/infrastructure/auth/adapters/data_mapper_sqla.py index 0c3c79e..bcde2f0 100644 --- a/src/app/infrastructure/auth_session/adapters/data_mapper_sqla.py +++ b/src/app/infrastructure/auth/adapters/data_mapper_sqla.py @@ -2,12 +2,12 @@ from sqlalchemy.exc import SQLAlchemyError from app.domain.value_objects.user_id import UserId -from app.infrastructure.auth_session.adapters.types import AuthAsyncSession -from app.infrastructure.auth_session.model import AuthSession -from app.infrastructure.auth_session.ports.gateway import ( +from app.infrastructure.adapters.constants import DB_QUERY_FAILED +from app.infrastructure.auth.adapters.types import AuthAsyncSession +from app.infrastructure.auth.session.model import AuthSession +from app.infrastructure.auth.session.ports.gateway import ( AuthSessionGateway, ) -from app.infrastructure.constants import DB_QUERY_FAILED from app.infrastructure.exceptions.gateway import DataMapperError diff --git a/src/app/infrastructure/auth_session/adapters/identity_provider.py b/src/app/infrastructure/auth/adapters/identity_provider.py similarity index 89% rename from src/app/infrastructure/auth_session/adapters/identity_provider.py rename to src/app/infrastructure/auth/adapters/identity_provider.py index 1b7e8a6..f79e580 100644 --- a/src/app/infrastructure/auth_session/adapters/identity_provider.py +++ b/src/app/infrastructure/auth/adapters/identity_provider.py @@ -1,6 +1,6 @@ from app.application.common.ports.identity_provider import IdentityProvider from app.domain.value_objects.user_id import UserId -from app.infrastructure.auth_session.service import AuthSessionService +from app.infrastructure.auth.session.service import AuthSessionService class AuthSessionIdentityProvider(IdentityProvider): diff --git a/src/app/infrastructure/auth_session/adapters/transaction_manager_sqla.py b/src/app/infrastructure/auth/adapters/transaction_manager_sqla.py similarity index 80% rename from src/app/infrastructure/auth_session/adapters/transaction_manager_sqla.py rename to src/app/infrastructure/auth/adapters/transaction_manager_sqla.py index fb2a5ed..b3590fc 100644 --- a/src/app/infrastructure/auth_session/adapters/transaction_manager_sqla.py +++ b/src/app/infrastructure/auth/adapters/transaction_manager_sqla.py @@ -2,15 +2,15 @@ from sqlalchemy.exc import SQLAlchemyError -from app.infrastructure.auth_session.adapters.types import AuthAsyncSession -from app.infrastructure.auth_session.ports.transaction_manager import ( - AuthSessionTransactionManager, -) -from app.infrastructure.constants import ( +from app.infrastructure.adapters.constants import ( DB_COMMIT_DONE, DB_COMMIT_FAILED, DB_QUERY_FAILED, ) +from app.infrastructure.auth.adapters.types import AuthAsyncSession +from app.infrastructure.auth.session.ports.transaction_manager import ( + AuthSessionTransactionManager, +) from app.infrastructure.exceptions.gateway import DataMapperError log = logging.getLogger(__name__) diff --git a/src/app/infrastructure/auth_session/adapters/types.py b/src/app/infrastructure/auth/adapters/types.py similarity index 100% rename from src/app/infrastructure/auth_session/adapters/types.py rename to src/app/infrastructure/auth/adapters/types.py diff --git a/src/app/infrastructure/exceptions/authentication.py b/src/app/infrastructure/auth/exceptions.py similarity index 100% rename from src/app/infrastructure/exceptions/authentication.py rename to src/app/infrastructure/auth/exceptions.py diff --git a/src/app/infrastructure/auth_session/ports/__init__.py b/src/app/infrastructure/auth/handlers/__init__.py similarity index 100% rename from src/app/infrastructure/auth_session/ports/__init__.py rename to src/app/infrastructure/auth/handlers/__init__.py diff --git a/src/app/infrastructure/auth/handlers/constants.py b/src/app/infrastructure/auth/handlers/constants.py new file mode 100644 index 0000000..4dd635e --- /dev/null +++ b/src/app/infrastructure/auth/handlers/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +AUTH_ACCOUNT_INACTIVE: Final[str] = "Your account is inactive. Please contact support." +AUTH_ALREADY_AUTHENTICATED: Final[str] = ( + "You are already authenticated. Consider logging out." +) diff --git a/src/app/infrastructure/handlers/log_in.py b/src/app/infrastructure/auth/handlers/log_in.py similarity index 92% rename from src/app/infrastructure/handlers/log_in.py rename to src/app/infrastructure/auth/handlers/log_in.py index a6b58e6..4d6e4d1 100644 --- a/src/app/infrastructure/handlers/log_in.py +++ b/src/app/infrastructure/auth/handlers/log_in.py @@ -8,16 +8,16 @@ from app.domain.services.user import UserService from app.domain.value_objects.raw_password.raw_password import RawPassword from app.domain.value_objects.username.username import Username -from app.infrastructure.auth_session.service import AuthSessionService -from app.infrastructure.constants import ( - AUTH_ACCOUNT_INACTIVE, - AUTH_ALREADY_AUTHENTICATED, - AUTH_INVALID_PASSWORD, -) -from app.infrastructure.exceptions.authentication import ( +from app.infrastructure.auth.exceptions import ( AlreadyAuthenticatedError, AuthenticationError, ) +from app.infrastructure.auth.handlers.constants import ( + AUTH_ACCOUNT_INACTIVE, + AUTH_ALREADY_AUTHENTICATED, +) +from app.infrastructure.auth.session.constants import AUTH_INVALID_PASSWORD +from app.infrastructure.auth.session.service import AuthSessionService log = logging.getLogger(__name__) diff --git a/src/app/infrastructure/handlers/log_out.py b/src/app/infrastructure/auth/handlers/log_out.py similarity index 94% rename from src/app/infrastructure/handlers/log_out.py rename to src/app/infrastructure/auth/handlers/log_out.py index 6d4bda7..215e9b0 100644 --- a/src/app/infrastructure/handlers/log_out.py +++ b/src/app/infrastructure/auth/handlers/log_out.py @@ -1,7 +1,7 @@ import logging from app.application.common.services.current_user import CurrentUserService -from app.infrastructure.auth_session.service import AuthSessionService +from app.infrastructure.auth.session.service import AuthSessionService log = logging.getLogger(__name__) diff --git a/src/app/infrastructure/handlers/sign_up.py b/src/app/infrastructure/auth/handlers/sign_up.py similarity index 94% rename from src/app/infrastructure/handlers/sign_up.py rename to src/app/infrastructure/auth/handlers/sign_up.py index 550446b..82b2aaf 100644 --- a/src/app/infrastructure/handlers/sign_up.py +++ b/src/app/infrastructure/auth/handlers/sign_up.py @@ -10,11 +10,13 @@ from app.domain.services.user import UserService from app.domain.value_objects.raw_password.raw_password import RawPassword from app.domain.value_objects.username.username import Username -from app.infrastructure.constants import AUTH_ALREADY_AUTHENTICATED -from app.infrastructure.exceptions.authentication import ( +from app.infrastructure.auth.exceptions import ( AlreadyAuthenticatedError, AuthenticationError, ) +from app.infrastructure.auth.handlers.constants import ( + AUTH_ALREADY_AUTHENTICATED, +) log = logging.getLogger(__name__) diff --git a/src/app/infrastructure/handlers/__init__.py b/src/app/infrastructure/auth/session/__init__.py similarity index 100% rename from src/app/infrastructure/handlers/__init__.py rename to src/app/infrastructure/auth/session/__init__.py diff --git a/src/app/infrastructure/constants.py b/src/app/infrastructure/auth/session/constants.py similarity index 50% rename from src/app/infrastructure/constants.py rename to src/app/infrastructure/auth/session/constants.py index 274306a..592409f 100644 --- a/src/app/infrastructure/constants.py +++ b/src/app/infrastructure/auth/session/constants.py @@ -1,22 +1,11 @@ from typing import Final -AUTH_ACCOUNT_INACTIVE: Final[str] = "Your account is inactive. Please contact support." -AUTH_ALREADY_AUTHENTICATED: Final[str] = ( - "You are already authenticated. Consider logging out." -) AUTH_INVALID_PASSWORD: Final[str] = "Invalid password." AUTH_IS_UNAVAILABLE: Final[str] = ( "Authentication is currently unavailable. Please try again later." ) AUTH_NOT_AUTHENTICATED: Final[str] = "Not authenticated." -AUTH_SESSION_NOT_FOUND: Final[str] = "Session not found." AUTH_SESSION_EXPIRED: Final[str] = "Session expired." AUTH_SESSION_EXTENSION_FAILED: Final[str] = "Auth session extension failed." AUTH_SESSION_EXTRACTION_FAILED: Final[str] = "Auth session extraction failed." - -DB_CONSTRAINT_VIOLATION: Final[str] = "Database constraint violation." -DB_COMMIT_DONE: Final[str] = "Commit was done." -DB_COMMIT_FAILED: Final[str] = "Commit failed." -DB_FLUSH_DONE: Final[str] = "Flush was done." -DB_FLUSH_FAILED: Final[str] = "Flush failed." -DB_QUERY_FAILED: Final[str] = "Database query failed." +AUTH_SESSION_NOT_FOUND: Final[str] = "Session not found." diff --git a/src/app/infrastructure/auth_session/id_generator_str.py b/src/app/infrastructure/auth/session/id_generator_str.py similarity index 100% rename from src/app/infrastructure/auth_session/id_generator_str.py rename to src/app/infrastructure/auth/session/id_generator_str.py diff --git a/src/app/infrastructure/auth_session/model.py b/src/app/infrastructure/auth/session/model.py similarity index 100% rename from src/app/infrastructure/auth_session/model.py rename to src/app/infrastructure/auth/session/model.py diff --git a/src/app/setup/ioc/di_providers/__init__.py b/src/app/infrastructure/auth/session/ports/__init__.py similarity index 100% rename from src/app/setup/ioc/di_providers/__init__.py rename to src/app/infrastructure/auth/session/ports/__init__.py diff --git a/src/app/infrastructure/auth_session/ports/gateway.py b/src/app/infrastructure/auth/session/ports/gateway.py similarity index 93% rename from src/app/infrastructure/auth_session/ports/gateway.py rename to src/app/infrastructure/auth/session/ports/gateway.py index dbb1a33..187cf32 100644 --- a/src/app/infrastructure/auth_session/ports/gateway.py +++ b/src/app/infrastructure/auth/session/ports/gateway.py @@ -2,7 +2,7 @@ from typing import Protocol from app.domain.value_objects.user_id import UserId -from app.infrastructure.auth_session.model import AuthSession +from app.infrastructure.auth.session.model import AuthSession class AuthSessionGateway(Protocol): diff --git a/src/app/infrastructure/auth_session/ports/transaction_manager.py b/src/app/infrastructure/auth/session/ports/transaction_manager.py similarity index 100% rename from src/app/infrastructure/auth_session/ports/transaction_manager.py rename to src/app/infrastructure/auth/session/ports/transaction_manager.py diff --git a/src/app/infrastructure/auth_session/ports/transport.py b/src/app/infrastructure/auth/session/ports/transport.py similarity index 83% rename from src/app/infrastructure/auth_session/ports/transport.py rename to src/app/infrastructure/auth/session/ports/transport.py index 1af5631..8b9350c 100644 --- a/src/app/infrastructure/auth_session/ports/transport.py +++ b/src/app/infrastructure/auth/session/ports/transport.py @@ -1,7 +1,7 @@ from abc import abstractmethod from typing import Protocol -from app.infrastructure.auth_session.model import AuthSession +from app.infrastructure.auth.session.model import AuthSession class AuthSessionTransport(Protocol): diff --git a/src/app/infrastructure/auth_session/service.py b/src/app/infrastructure/auth/session/service.py similarity index 93% rename from src/app/infrastructure/auth_session/service.py rename to src/app/infrastructure/auth/session/service.py index 703040b..0142e34 100644 --- a/src/app/infrastructure/auth_session/service.py +++ b/src/app/infrastructure/auth/session/service.py @@ -2,17 +2,8 @@ from datetime import datetime from app.domain.value_objects.user_id import UserId -from app.infrastructure.auth_session.id_generator_str import StrAuthSessionIdGenerator -from app.infrastructure.auth_session.model import AuthSession -from app.infrastructure.auth_session.ports.gateway import ( - AuthSessionGateway, -) -from app.infrastructure.auth_session.ports.transaction_manager import ( - AuthSessionTransactionManager, -) -from app.infrastructure.auth_session.ports.transport import AuthSessionTransport -from app.infrastructure.auth_session.timer_utc import UtcAuthSessionTimer -from app.infrastructure.constants import ( +from app.infrastructure.auth.exceptions import AuthenticationError +from app.infrastructure.auth.session.constants import ( AUTH_IS_UNAVAILABLE, AUTH_NOT_AUTHENTICATED, AUTH_SESSION_EXPIRED, @@ -20,7 +11,18 @@ AUTH_SESSION_EXTRACTION_FAILED, AUTH_SESSION_NOT_FOUND, ) -from app.infrastructure.exceptions.authentication import AuthenticationError +from app.infrastructure.auth.session.id_generator_str import ( + StrAuthSessionIdGenerator, +) +from app.infrastructure.auth.session.model import AuthSession +from app.infrastructure.auth.session.ports.gateway import ( + AuthSessionGateway, +) +from app.infrastructure.auth.session.ports.transaction_manager import ( + AuthSessionTransactionManager, +) +from app.infrastructure.auth.session.ports.transport import AuthSessionTransport +from app.infrastructure.auth.session.timer_utc import UtcAuthSessionTimer from app.infrastructure.exceptions.gateway import DataMapperError log = logging.getLogger(__name__) diff --git a/src/app/infrastructure/auth_session/timer_utc.py b/src/app/infrastructure/auth/session/timer_utc.py similarity index 100% rename from src/app/infrastructure/auth_session/timer_utc.py rename to src/app/infrastructure/auth/session/timer_utc.py diff --git a/src/app/infrastructure/persistence_sqla/mappings/auth_session.py b/src/app/infrastructure/persistence_sqla/mappings/auth_session.py index 7cd5240..419ca33 100644 --- a/src/app/infrastructure/persistence_sqla/mappings/auth_session.py +++ b/src/app/infrastructure/persistence_sqla/mappings/auth_session.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import composite from app.domain.value_objects.user_id import UserId -from app.infrastructure.auth_session.model import AuthSession +from app.infrastructure.auth.session.model import AuthSession from app.infrastructure.persistence_sqla.registry import mapping_registry auth_sessions_table = Table( diff --git a/src/app/infrastructure/persistence_sqla/provider.py b/src/app/infrastructure/persistence_sqla/provider.py index d1316af..db1f2cd 100644 --- a/src/app/infrastructure/persistence_sqla/provider.py +++ b/src/app/infrastructure/persistence_sqla/provider.py @@ -12,7 +12,7 @@ from app.infrastructure.adapters.types import ( MainAsyncSession, ) -from app.infrastructure.auth_session.adapters.types import AuthAsyncSession +from app.infrastructure.auth.adapters.types import AuthAsyncSession from app.infrastructure.persistence_sqla.config import PostgresDsn, SqlaEngineConfig log = logging.getLogger(__name__) diff --git a/src/app/presentation/http/auth/access_token_processor_jwt.py b/src/app/presentation/http/auth/access_token_processor_jwt.py index 57a06f6..c59773e 100644 --- a/src/app/presentation/http/auth/access_token_processor_jwt.py +++ b/src/app/presentation/http/auth/access_token_processor_jwt.py @@ -3,7 +3,7 @@ import jwt -from app.infrastructure.auth_session.model import AuthSession +from app.infrastructure.auth.session.model import AuthSession from app.presentation.http.auth.constants import ( ACCESS_TOKEN_INVALID_OR_EXPIRED, ACCESS_TOKEN_PAYLOAD_MISSING, diff --git a/src/app/presentation/http/auth/adapters/session_transport_jwt_cookie.py b/src/app/presentation/http/auth/adapters/session_transport_jwt_cookie.py index 8e126ac..71c8569 100644 --- a/src/app/presentation/http/auth/adapters/session_transport_jwt_cookie.py +++ b/src/app/presentation/http/auth/adapters/session_transport_jwt_cookie.py @@ -2,8 +2,8 @@ from starlette.requests import Request -from app.infrastructure.auth_session.model import AuthSession -from app.infrastructure.auth_session.ports.transport import AuthSessionTransport +from app.infrastructure.auth.session.model import AuthSession +from app.infrastructure.auth.session.ports.transport import AuthSessionTransport from app.presentation.http.auth.access_token_processor_jwt import ( JwtAccessTokenProcessor, ) diff --git a/src/app/presentation/http/controllers/account/log_in.py b/src/app/presentation/http/controllers/account/log_in.py index f3f6c4a..d804b3a 100644 --- a/src/app/presentation/http/controllers/account/log_in.py +++ b/src/app/presentation/http/controllers/account/log_in.py @@ -2,7 +2,7 @@ from dishka.integrations.fastapi import inject from fastapi import APIRouter, status -from app.infrastructure.handlers.log_in import LogInHandler, LogInRequest +from app.infrastructure.auth.handlers.log_in import LogInHandler, LogInRequest from app.presentation.http.exceptions.schemas import ( ExceptionSchema, ExceptionSchemaDetailed, diff --git a/src/app/presentation/http/controllers/account/log_out.py b/src/app/presentation/http/controllers/account/log_out.py index 3672d44..1bdbd83 100644 --- a/src/app/presentation/http/controllers/account/log_out.py +++ b/src/app/presentation/http/controllers/account/log_out.py @@ -2,7 +2,7 @@ from dishka.integrations.fastapi import inject from fastapi import APIRouter, Security, status -from app.infrastructure.handlers.log_out import LogOutHandler +from app.infrastructure.auth.handlers.log_out import LogOutHandler from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme from app.presentation.http.exceptions.schemas import ( ExceptionSchema, diff --git a/src/app/presentation/http/controllers/account/sign_up.py b/src/app/presentation/http/controllers/account/sign_up.py index 3b9b5ba..2c91670 100644 --- a/src/app/presentation/http/controllers/account/sign_up.py +++ b/src/app/presentation/http/controllers/account/sign_up.py @@ -2,7 +2,7 @@ from dishka.integrations.fastapi import inject from fastapi import APIRouter, status -from app.infrastructure.handlers.sign_up import ( +from app.infrastructure.auth.handlers.sign_up import ( SignUpHandler, SignUpRequest, SignUpResponse, diff --git a/src/app/presentation/http/exceptions/constants.py b/src/app/presentation/http/exceptions/constants.py index 1e844e0..4ebfbe5 100644 --- a/src/app/presentation/http/exceptions/constants.py +++ b/src/app/presentation/http/exceptions/constants.py @@ -16,7 +16,7 @@ UsernameAlreadyExistsError, UserNotFoundByUsernameError, ) -from app.infrastructure.exceptions.authentication import ( +from app.infrastructure.auth.exceptions import ( AlreadyAuthenticatedError, AuthenticationError, ) diff --git a/src/app/run.py b/src/app/run.py index f8d153b..99b5586 100644 --- a/src/app/run.py +++ b/src/app/run.py @@ -6,7 +6,7 @@ from app.setup.app_factory import configure_app, create_app, create_async_ioc_container from app.setup.config.logs import configure_logging from app.setup.config.settings import AppSettings, load_settings -from app.setup.ioc.registry import get_providers +from app.setup.ioc.provider_registry import get_providers def make_app( diff --git a/src/app/setup/config/loader.py b/src/app/setup/config/loader.py index 3aa8e11..f444e5f 100644 --- a/src/app/setup/config/loader.py +++ b/src/app/setup/config/loader.py @@ -8,8 +8,12 @@ import rtoml +ConfigDict = dict[str, Any] + log = logging.getLogger(__name__) +ENV_VAR_NAME: Final[str] = "APP_ENV" + class ValidEnvs(StrEnum): """ @@ -32,8 +36,6 @@ class DirContents(StrEnum): DOTENV_NAME = ".env" -ENV_VAR_NAME: Final[str] = "APP_ENV" - BASE_DIR_PATH = Path(__file__).resolve().parents[4] CONFIG_PATH: Final[Path] = BASE_DIR_PATH / "config" @@ -44,7 +46,7 @@ class DirContents(StrEnum): }) -def validate_env(*, env: str | None) -> ValidEnvs: +def validate_env(env: str | None) -> ValidEnvs: if env is None: raise ValueError(f"{ENV_VAR_NAME} is not set.") try: @@ -57,16 +59,30 @@ def validate_env(*, env: str | None) -> ValidEnvs: def get_current_env() -> ValidEnvs: - env_value = os.getenv(ENV_VAR_NAME) - return validate_env(env=env_value) + return validate_env(os.getenv(ENV_VAR_NAME)) + + +def load_full_config( + env: ValidEnvs, + dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS, + main_config: DirContents = DirContents.CONFIG_NAME, + secrets_config: DirContents = DirContents.SECRETS_NAME, +) -> ConfigDict: + log.info("Reading config for environment: '%s'", env) + config = read_config(env=env, config=main_config, dir_paths=dir_paths) + try: + secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths) + except FileNotFoundError: + log.warning("Secrets file not found. Full config will not contain secrets.") + return config + return merge_dicts(dict1=config, dict2=secrets) def read_config( - *, env: ValidEnvs, - config: DirContents, dir_paths: Mapping[ValidEnvs, Path], -) -> dict[str, Any]: + config: DirContents, +) -> ConfigDict: dir_path = dir_paths.get(env) if dir_path is None: raise FileNotFoundError(f"No directory path configured for environment: {env}") @@ -79,7 +95,7 @@ def read_config( return rtoml.load(file) -def merge_dicts(*, dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]: +def merge_dicts(*, dict1: ConfigDict, dict2: ConfigDict) -> ConfigDict: result = dict1.copy() for key, value in dict2.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): @@ -87,20 +103,3 @@ def merge_dicts(*, dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, An else: result[key] = value return result - - -def load_full_config( - *, - env: ValidEnvs, - main_config: DirContents = DirContents.CONFIG_NAME, - secrets_config: DirContents = DirContents.SECRETS_NAME, - dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS, -) -> dict[str, Any]: - log.info("Reading config for environment: '%s'", env) - config = read_config(env=env, config=main_config, dir_paths=dir_paths) - try: - secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths) - except FileNotFoundError: - log.warning("Secrets file not found. Full config will not contain secrets.") - return config - return merge_dicts(dict1=config, dict2=secrets) diff --git a/src/app/setup/ioc/di_providers/application.py b/src/app/setup/ioc/application.py similarity index 94% rename from src/app/setup/ioc/di_providers/application.py rename to src/app/setup/ioc/application.py index 7a76847..d933e81 100644 --- a/src/app/setup/ioc/di_providers/application.py +++ b/src/app/setup/ioc/application.py @@ -22,10 +22,10 @@ SqlaUserDataMapper, ) from app.infrastructure.adapters.user_reader_sqla import SqlaUserReader -from app.infrastructure.auth_session.adapters.access_revoker import ( +from app.infrastructure.auth.adapters.access_revoker import ( AuthSessionAccessRevoker, ) -from app.infrastructure.auth_session.adapters.identity_provider import ( +from app.infrastructure.auth.adapters.identity_provider import ( AuthSessionIdentityProvider, ) diff --git a/src/app/setup/ioc/di_providers/domain.py b/src/app/setup/ioc/domain.py similarity index 100% rename from src/app/setup/ioc/di_providers/domain.py rename to src/app/setup/ioc/domain.py diff --git a/src/app/setup/ioc/di_providers/infrastructure.py b/src/app/setup/ioc/infrastructure.py similarity index 74% rename from src/app/setup/ioc/di_providers/infrastructure.py rename to src/app/setup/ioc/infrastructure.py index 561c344..3275dae 100644 --- a/src/app/setup/ioc/di_providers/infrastructure.py +++ b/src/app/setup/ioc/infrastructure.py @@ -7,26 +7,28 @@ SqlaUserDataMapper, ) from app.infrastructure.adapters.user_reader_sqla import SqlaUserReader -from app.infrastructure.auth_session.adapters.data_mapper_sqla import ( +from app.infrastructure.auth.adapters.data_mapper_sqla import ( SqlaAuthSessionDataMapper, ) -from app.infrastructure.auth_session.adapters.identity_provider import ( +from app.infrastructure.auth.adapters.identity_provider import ( AuthSessionIdentityProvider, ) -from app.infrastructure.auth_session.adapters.transaction_manager_sqla import ( +from app.infrastructure.auth.adapters.transaction_manager_sqla import ( SqlaAuthSessionTransactionManager, ) -from app.infrastructure.auth_session.id_generator_str import StrAuthSessionIdGenerator -from app.infrastructure.auth_session.ports.gateway import AuthSessionGateway -from app.infrastructure.auth_session.ports.transaction_manager import ( +from app.infrastructure.auth.handlers.log_in import LogInHandler +from app.infrastructure.auth.handlers.log_out import LogOutHandler +from app.infrastructure.auth.handlers.sign_up import SignUpHandler +from app.infrastructure.auth.session.id_generator_str import ( + StrAuthSessionIdGenerator, +) +from app.infrastructure.auth.session.ports.gateway import AuthSessionGateway +from app.infrastructure.auth.session.ports.transaction_manager import ( AuthSessionTransactionManager, ) -from app.infrastructure.auth_session.ports.transport import AuthSessionTransport -from app.infrastructure.auth_session.service import AuthSessionService -from app.infrastructure.auth_session.timer_utc import UtcAuthSessionTimer -from app.infrastructure.handlers.log_in import LogInHandler -from app.infrastructure.handlers.log_out import LogOutHandler -from app.infrastructure.handlers.sign_up import SignUpHandler +from app.infrastructure.auth.session.ports.transport import AuthSessionTransport +from app.infrastructure.auth.session.service import AuthSessionService +from app.infrastructure.auth.session.timer_utc import UtcAuthSessionTimer from app.infrastructure.persistence_sqla.provider import ( get_async_engine, get_async_session_factory, diff --git a/src/app/setup/ioc/di_providers/presentation.py b/src/app/setup/ioc/presentation.py similarity index 100% rename from src/app/setup/ioc/di_providers/presentation.py rename to src/app/setup/ioc/presentation.py diff --git a/src/app/setup/ioc/provider_registry.py b/src/app/setup/ioc/provider_registry.py new file mode 100644 index 0000000..9bdceea --- /dev/null +++ b/src/app/setup/ioc/provider_registry.py @@ -0,0 +1,19 @@ +from collections.abc import Iterable + +from dishka import Provider + +from app.setup.ioc.application import ApplicationProvider +from app.setup.ioc.domain import DomainProvider +from app.setup.ioc.infrastructure import infrastructure_provider +from app.setup.ioc.presentation import PresentationProvider +from app.setup.ioc.settings import SettingsProvider + + +def get_providers() -> Iterable[Provider]: + return ( + DomainProvider(), + ApplicationProvider(), + infrastructure_provider(), + PresentationProvider(), + SettingsProvider(), + ) diff --git a/src/app/setup/ioc/registry.py b/src/app/setup/ioc/registry.py deleted file mode 100644 index c6a0468..0000000 --- a/src/app/setup/ioc/registry.py +++ /dev/null @@ -1,19 +0,0 @@ -from collections.abc import Iterable - -from dishka import Provider - -from app.setup.ioc.di_providers.application import ApplicationProvider -from app.setup.ioc.di_providers.domain import DomainProvider -from app.setup.ioc.di_providers.infrastructure import infrastructure_provider -from app.setup.ioc.di_providers.presentation import PresentationProvider -from app.setup.ioc.di_providers.settings import SettingsProvider - - -def get_providers() -> Iterable[Provider]: - return ( - DomainProvider(), - ApplicationProvider(), - infrastructure_provider(), - PresentationProvider(), - SettingsProvider(), - ) diff --git a/src/app/setup/ioc/di_providers/settings.py b/src/app/setup/ioc/settings.py similarity index 97% rename from src/app/setup/ioc/di_providers/settings.py rename to src/app/setup/ioc/settings.py index 449a559..7f7f70b 100644 --- a/src/app/setup/ioc/di_providers/settings.py +++ b/src/app/setup/ioc/settings.py @@ -1,7 +1,7 @@ from dishka import Provider, Scope, from_context, provide from app.infrastructure.adapters.password_hasher_bcrypt import PasswordPepper -from app.infrastructure.auth_session.timer_utc import ( +from app.infrastructure.auth.session.timer_utc import ( AuthSessionRefreshThreshold, AuthSessionTtlMin, ) diff --git a/tests/app/unit/setup/test_cfg_loader.py b/tests/app/unit/setup/test_cfg_loader.py index 2c0635b..4181737 100644 --- a/tests/app/unit/setup/test_cfg_loader.py +++ b/tests/app/unit/setup/test_cfg_loader.py @@ -18,7 +18,7 @@ @pytest.mark.parametrize("env", list(ValidEnvs)) def test_returns_enum_for_correct_env_string(env: ValidEnvs) -> None: - assert validate_env(env=env) == env + assert validate_env(env) == env @pytest.mark.parametrize( @@ -27,7 +27,7 @@ def test_returns_enum_for_correct_env_string(env: ValidEnvs) -> None: ) def test_raises_for_incorrect_env_string_or_none(env: str | None) -> None: with pytest.raises(ValueError): - validate_env(env=env) + validate_env(env) @pytest.mark.parametrize("env_str", list(ValidEnvs))