diff --git a/backend/app/.mypy.ini b/backend/app/.mypy.ini index 2a7fdbb..3110728 100644 --- a/backend/app/.mypy.ini +++ b/backend/app/.mypy.ini @@ -1,4 +1,3 @@ [mypy] files = app/ -plugins = sqlmypy -ignore_missing_imports = True \ No newline at end of file +plugins = sqlmypy \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index e09b396..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,42 +0,0 @@ -import os - - -class MySqlConfig: - MYSQL_USER = os.environ.get("MYSQL_USER") - MYSQL_DATABASE = os.environ.get("MYSQL_DATABASE") - MYSQL_HOST = os.environ.get("MYSQL_HOST") - MYSQL_PORT = os.environ.get("MYSQL_PORT") - MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD") - - -class RedisConfig: - REDIS_HOST = os.environ.get("REDIS_HOST") - REDIS_PORT = os.environ.get("REDIS_PORT") - REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD") - - -class FlaskProduction: - DEBUG = False - JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") - SERVER_NAME = os.environ.get("HOST") + ":" + os.environ.get("PORT") - - MAIL_SERVER = os.environ.get("MAIL_SERVER") - MAIL_PORT = os.environ.get("MAIL_PORT") - MAIL_USERNAME = os.environ.get("MAIL_USERNAME") - MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") - MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") - MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER") - - -class FlaskTesting: - DEBUG = True - TESTING = True - JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") - SERVER_NAME = os.environ.get("HOST") + ":" + os.environ.get("PORT") - - MAIL_SERVER = os.environ.get("MAIL_SERVER") - MAIL_PORT = os.environ.get("MAIL_PORT") - MAIL_USERNAME = os.environ.get("MAIL_USERNAME") - MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") - MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") - MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER") diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..0670c23 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,3 @@ +from .config import * + +__all__ = [*config.__all__] diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..9344c87 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,190 @@ +import os +import logging +import re +from typing import Any, Type, Optional, Callable + +import dotenv + +from .exceptions import ConfigError + + +class EnvConfigField(): + """ + Represents a single config field to be parsed from the environment. + + :param env_name: Name of the environment variable + :param data_type: Expected data type (int, str, etc.) + :param default: Default value if not set in environment + :param required: Whether the variable is required + :param choices: List of allowed values (for enums) + :param min_value: Minimum value (for integers) + :param max_value: Maximum value (for integers) + :param regex: Regex pattern (for string validation) + :param parser: Custom parser function for complex validation + """ + + def __init__( + self, + env_name: str, + data_type: Type[Any] = str, + default: Any = None, + required: bool = False, + choices: Optional[list[Any]] = None, + min_value: Optional[int] = None, + max_value: Optional[int] = None, + regex: Optional[str] = None, + parser: Optional[Callable[[str], Any]] = None, + ): + self.value = None + self.env_name = env_name + self.data_type = data_type + self.default = default + self.required = required + self.choices = choices + self.min_value = min_value + self.max_value = max_value + self.regex = re.compile(regex) if regex else None + self.parser = parser + + def load(self): + """Loads the value from the environment and validates it.""" + raw_value = os.environ.get(self.env_name) + + if self.required and (raw_value is None or raw_value == ""): + raise ConfigError( + "Field %(env_name)s is required, but was not provided in the environment / .env", + env_name=self.env_name, + ) + + self.value = self.validate(raw_value) + + def validate(self, value: Any) -> Any: + """Validates the value based on provided constraints.""" + + # Use the default value if available and skip validation + if (value is None or value == "") and self.default is not None: + return self.default + + # Use custom parser if provided + if self.parser: + value = self.parser(value) + + # Convert value to expected data type + if self.data_type == str: + value = self.__parse_to_str(value) + elif self.data_type == int: + value = self.__parse_to_int(value) + elif self.data_type == bool: + value = self.__parse_to_bool(value) + + # Validate allowed choices + if self.choices and value not in self.choices: + raise ConfigError( + "Value for %(env_name)s - %(value)s is not one of the following allowed values: %(allowed_values)s", + env_name=self.env_name, + value=value, + allowed_values=", ".join(map(str, self.choices)), + ) + + return value + + @property + def value(self): + return self.value + + @value.setter + def value(self, new): + raise TypeError("Cannot modify") + + def __parse_to_str(self, value) -> str: + """Parses and validates string values.""" + value = str(value) + + if self.regex and not self.regex.match(value): + raise ConfigError( + "Value for %(env_name)s - %(value)s does not match the expected pattern: %(regex)s", + env_name=self.env_name, + value=value, + regex=self.regex.pattern, + ) + + return value + + def __parse_to_int(self, value) -> int: + """Parses and validates integer values.""" + if not str(value).isdigit(): + raise ConfigError( + "Value for %(env_name)s - %(value)s must be a numeric integer", + env_name=self.env_name, + value=value, + ) + + value = int(value) + + if self.min_value is not None and value < self.min_value: + raise ConfigError( + "Value for %(env_name)s - %(value)d is too small. Must be greater than or equal to %(min_value)d", + env_name=self.env_name, + value=value, + min_value=self.min_value, + ) + + if self.max_value is not None and value > self.max_value: + raise ConfigError( + "Value for %(env_name)s - %(value)d is too large. Must be smaller than or equal to %(max_value)d", + env_name=self.env_name, + value=value, + max_value=self.max_value, + ) + + return value + + def __parse_to_bool(self, value): + value = str(value).lower() + + true_values = ["1", "true", "yes"] + false_values = ["0", "false", "no"] + + if value in true_values: + return True + if value in false_values: + return False + + raise ConfigError("Value for %(env_name)s - %(value)s cannot be converted to a true / false value", + env_name=self.env_name, + value=value) + + +class EnvConfig(): + def __init__(self): + self.logger = logging.getLogger(__name__) + + self.fields = {} + + def add_field(self, name: str, field: EnvConfigField): + if name in self.fields: + return + + self.fields[name] = field + + def load_config(self): + dotenv.load_dotenv() + + for field in self.fields.values(): + try: + field.load + except ConfigError as e: + self.logger.error("Configuration error: %s", e) + raise + + def __getitem__(self, key: str): + return self.fields["key"].value + + def __contains__(self, key: str) -> bool: + return key in self.fields + + def to_dict(self): + return {attr: getattr(self, attr) for attr in self.fields} + + +__all__ = ["EnvConfig"] diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..b61337a --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,12 @@ +class SwagShopError(Exception): + def __init__(self, message): + super().__init__(message) + self.message = message + +class ConfigError(SwagShopError): + def __init__(self, message, **kwargs): + formatted_message = message % kwargs + super().__init__(formatted_message) + self.message = formatted_message + +__all__ = ["SwagShopError", "ConfigError"] diff --git a/backend/app/main.py b/backend/app/main.py index 4088510..eb18cfe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from .routes.cart_routes import router as cart_router from .routes.user_routes import router as user_router +from .routes.shop_routes import router as shop_router app = FastAPI( @@ -10,6 +11,7 @@ app = FastAPI( app.include_router(user_router) app.include_router(cart_router) +app.include_router(shop_router) @app.get("/") diff --git a/backend/app/routes/shop_routes.py b/backend/app/routes/shop_routes.py index e69de29..f033b78 100644 --- a/backend/app/routes/shop_routes.py +++ b/backend/app/routes/shop_routes.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +router = APIRouter( + prefix="/shop", + tags=["Shop"] +) + + +@router.get("/") +async def get_shop_info(): + raise NotImplementedError diff --git a/backend/app/utils/jwt_utils.py b/backend/app/utils/jwt_utils.py new file mode 100644 index 0000000..3fff27c --- /dev/null +++ b/backend/app/utils/jwt_utils.py @@ -0,0 +1,13 @@ +from app.extensions import jwt_redis_blocklist + +from . import jwt_manager + +from app import app + + +@jwt_manager.token_in_blocklist_loader +def check_if_token_is_revoked(jwt_header, jwt_payload: dict) -> bool: + jti = jwt_payload["jti"] + token_in_redis = jwt_redis_blocklist.get(jti) + + return token_in_redis is not None diff --git a/backend/poetry.lock b/backend/poetry.lock index 7c82127..a8c000b 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -381,6 +381,20 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "pyyaml" version = "6.0.2" @@ -600,4 +614,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "b8a25a5b186862b7ea42474e850ed664aa26925d64f0a89e98bf67cd4fd862f8" +content-hash = "250084c97cedad74f83b3a3420c71f338eb85d309bf30aed8e84743a3c2b26e3" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 548ae26..573e297 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,11 +4,13 @@ version = "0.1.0" description = "" authors = ["Thastertyn "] readme = "README.md" +package-mode = false [tool.poetry.dependencies] python = "^3.12" fastapi = "^0.115.6" sqlalchemy = "^2.0.37" +python-dotenv = "^1.0.1" [tool.poetry.group.dev.dependencies]