[rewrite] Added a config manager and some test code
This commit is contained in:
		
							parent
							
								
									807d23da51
								
							
						
					
					
						commit
						3c005dbfa4
					
				@ -1,4 +1,3 @@
 | 
			
		||||
[mypy]
 | 
			
		||||
files = app/
 | 
			
		||||
plugins = sqlmypy
 | 
			
		||||
ignore_missing_imports = True
 | 
			
		||||
plugins = sqlmypy
 | 
			
		||||
@ -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")
 | 
			
		||||
							
								
								
									
										3
									
								
								backend/app/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								backend/app/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
from .config import *
 | 
			
		||||
 | 
			
		||||
__all__ = [*config.__all__]
 | 
			
		||||
							
								
								
									
										190
									
								
								backend/app/core/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								backend/app/core/config.py
									
									
									
									
									
										Normal file
									
								
							@ -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"]
 | 
			
		||||
							
								
								
									
										12
									
								
								backend/app/core/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								backend/app/core/exceptions.py
									
									
									
									
									
										Normal file
									
								
							@ -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"]
 | 
			
		||||
@ -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("/")
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,11 @@
 | 
			
		||||
from fastapi import APIRouter
 | 
			
		||||
 | 
			
		||||
router = APIRouter(
 | 
			
		||||
    prefix="/shop",
 | 
			
		||||
    tags=["Shop"]
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@router.get("/")
 | 
			
		||||
async def get_shop_info():
 | 
			
		||||
    raise NotImplementedError
 | 
			
		||||
							
								
								
									
										13
									
								
								backend/app/utils/jwt_utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								backend/app/utils/jwt_utils.py
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
							
								
								
									
										16
									
								
								backend/poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										16
									
								
								backend/poetry.lock
									
									
									
										generated
									
									
									
								
							@ -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"
 | 
			
		||||
 | 
			
		||||
@ -4,11 +4,13 @@ version = "0.1.0"
 | 
			
		||||
description = ""
 | 
			
		||||
authors = ["Thastertyn <thastertyn@gmail.com>"]
 | 
			
		||||
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]
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user