From fb23e3c7cf6d6bd8c5cd0649d9efd91319067033 Mon Sep 17 00:00:00 2001 From: zanewalker Date: Sun, 1 Feb 2026 13:59:32 +0000 Subject: [PATCH] feat: add configuration loader for .env and JSON files - Add ConfigLoader class for loading RateLimitConfig and GlobalConfig - Support .env files with FASTAPI_TRAFFIC_* prefixed variables - Support JSON configuration files with type validation - Add convenience functions: load_rate_limit_config, load_global_config - Add load_rate_limit_config_from_env, load_global_config_from_env - Support custom environment variable prefixes - Add comprehensive error handling with ConfigurationError - Add 47 tests for configuration loading - Add example 11_config_loader.py with 9 usage patterns - Update examples/README.md with config loader documentation - Update CHANGELOG.md with new feature - Fix typo in limiter.py (errant 'fi' on line 4) --- CHANGELOG.md | 10 + examples/11_config_loader.py | 441 +++++++++++++++++++ examples/README.md | 37 ++ fastapi_traffic/__init__.py | 35 +- fastapi_traffic/core/__init__.py | 19 +- fastapi_traffic/core/config_loader.py | 532 +++++++++++++++++++++++ fastapi_traffic/core/limiter.py | 16 +- tests/test_config_loader.py | 599 ++++++++++++++++++++++++++ 8 files changed, 1665 insertions(+), 24 deletions(-) create mode 100644 examples/11_config_loader.py create mode 100644 fastapi_traffic/core/config_loader.py create mode 100644 tests/test_config_loader.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 49eade3..7a0e8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Configuration Loader** - Load rate limiting configuration from external files: + - `ConfigLoader` class for loading `RateLimitConfig` and `GlobalConfig` + - Support for `.env` files with `FASTAPI_TRAFFIC_*` prefixed variables + - Support for JSON configuration files + - Environment variable loading with `load_rate_limit_config_from_env()` and `load_global_config_from_env()` + - Auto-detection of file format with `load_rate_limit_config()` and `load_global_config()` + - Custom environment variable prefix support + - Type validation and comprehensive error handling + - 47 new tests for configuration loading +- Example `11_config_loader.py` demonstrating all configuration loading patterns - `get_stats()` method to `MemoryBackend` for consistency with `RedisBackend` - Comprehensive test suite with 134 tests covering: - All five rate limiting algorithms with timing and concurrency tests diff --git a/examples/11_config_loader.py b/examples/11_config_loader.py new file mode 100644 index 0000000..9a0e76e --- /dev/null +++ b/examples/11_config_loader.py @@ -0,0 +1,441 @@ +"""Examples demonstrating configuration loading from .env and JSON files. + +This module shows how to load rate limiting configuration from external files, +making it easy to manage settings across different environments (dev, staging, prod). +""" + +from __future__ import annotations + +import json +import os +import tempfile +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + ConfigLoader, + MemoryBackend, + RateLimiter, + RateLimitExceeded, + load_global_config, + load_global_config_from_env, + load_rate_limit_config, + load_rate_limit_config_from_env, + rate_limit, +) +from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig +from fastapi_traffic.core.limiter import set_limiter +from fastapi_traffic.exceptions import ConfigurationError + +# ============================================================================= +# Example 1: Loading RateLimitConfig from environment variables +# ============================================================================= + + +def example_env_variables() -> RateLimitConfig: + """Load rate limit config from environment variables. + + Set these environment variables before running: + export FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100 + export FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=60.0 + export FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=sliding_window_counter + export FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=myapi + """ + # Using the convenience function + config = load_rate_limit_config_from_env( + # You can provide overrides for values not in env vars + limit=50, # Default if FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT not set + ) + print(f"Loaded config: limit={config.limit}, window={config.window_size}s") + return config + + +# ============================================================================= +# Example 2: Loading GlobalConfig from environment variables +# ============================================================================= + + +def example_global_config_env() -> GlobalConfig: + """Load global config from environment variables. + + Set these environment variables: + export FASTAPI_TRAFFIC_GLOBAL_ENABLED=true + export FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=200 + export FASTAPI_TRAFFIC_GLOBAL_DEFAULT_WINDOW_SIZE=120.0 + export FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS=127.0.0.1,10.0.0.1 + export FASTAPI_TRAFFIC_GLOBAL_EXEMPT_PATHS=/health,/metrics + """ + config = load_global_config_from_env() + print(f"Global config: enabled={config.enabled}, limit={config.default_limit}") + print(f"Exempt IPs: {config.exempt_ips}") + print(f"Exempt paths: {config.exempt_paths}") + return config + + +# ============================================================================= +# Example 3: Loading from .env file +# ============================================================================= + + +def example_dotenv_file() -> RateLimitConfig: + """Load rate limit config from a .env file. + + Example .env file contents: + # Rate limiting configuration + FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100 + FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=60.0 + FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=token_bucket + FASTAPI_TRAFFIC_RATE_LIMIT_BURST_SIZE=20 + FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=api_v1 + FASTAPI_TRAFFIC_RATE_LIMIT_INCLUDE_HEADERS=true + FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Too many requests, please slow down" + """ + # Create a sample .env file for demonstration + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("# Rate limit configuration\n") + f.write("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100\n") + f.write("FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=60.0\n") + f.write("FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=token_bucket\n") + f.write("FASTAPI_TRAFFIC_RATE_LIMIT_BURST_SIZE=20\n") + f.write('FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Rate limit exceeded"\n') + env_path = f.name + + try: + # Load using auto-detection (detects .env suffix) + config = load_rate_limit_config(env_path) + print(f"From .env: limit={config.limit}, algorithm={config.algorithm}") + print(f"Burst size: {config.burst_size}") + return config + finally: + Path(env_path).unlink() + + +# ============================================================================= +# Example 4: Loading from JSON file +# ============================================================================= + + +def example_json_file() -> RateLimitConfig: + """Load rate limit config from a JSON file. + + Example config.json: + { + "limit": 500, + "window_size": 300.0, + "algorithm": "sliding_window_counter", + "key_prefix": "production", + "include_headers": true, + "status_code": 429, + "cost": 1 + } + """ + # Create a sample JSON file for demonstration + config_data = { + "limit": 500, + "window_size": 300.0, + "algorithm": "sliding_window_counter", + "key_prefix": "production", + "include_headers": True, + "status_code": 429, + "skip_on_error": False, + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(config_data, f, indent=2) + json_path = f.name + + try: + # Load using auto-detection (detects .json suffix) + config = load_rate_limit_config(json_path) + print(f"From JSON: limit={config.limit}, window={config.window_size}s") + print(f"Algorithm: {config.algorithm.value}") + return config + finally: + Path(json_path).unlink() + + +# ============================================================================= +# Example 5: Loading GlobalConfig from JSON +# ============================================================================= + + +def example_global_config_json() -> GlobalConfig: + """Load global config from a JSON file. + + Example global_config.json: + { + "enabled": true, + "default_limit": 1000, + "default_window_size": 60.0, + "default_algorithm": "sliding_window_counter", + "key_prefix": "myapp", + "include_headers": true, + "exempt_ips": ["127.0.0.1", "::1", "10.0.0.0/8"], + "exempt_paths": ["/health", "/ready", "/metrics", "/docs"] + } + """ + config_data = { + "enabled": True, + "default_limit": 1000, + "default_window_size": 60.0, + "default_algorithm": "sliding_window_counter", + "key_prefix": "myapp", + "exempt_ips": ["127.0.0.1", "::1"], + "exempt_paths": ["/health", "/ready", "/metrics"], + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(config_data, f, indent=2) + json_path = f.name + + try: + config = load_global_config(json_path) + print(f"Global: enabled={config.enabled}, limit={config.default_limit}") + print(f"Exempt paths: {config.exempt_paths}") + return config + finally: + Path(json_path).unlink() + + +# ============================================================================= +# Example 6: Using ConfigLoader class with custom prefix +# ============================================================================= + + +def example_custom_prefix() -> RateLimitConfig: + """Use ConfigLoader with a custom environment variable prefix. + + Useful when you want to namespace your config variables differently, + e.g., for different services or to avoid conflicts. + """ + # Create a loader with custom prefix + loader = ConfigLoader(env_prefix="MYAPP_RATELIMIT_") + + # Simulated environment variables with custom prefix + env_vars = { + "MYAPP_RATELIMIT_RATE_LIMIT_LIMIT": "250", + "MYAPP_RATELIMIT_RATE_LIMIT_WINDOW_SIZE": "30.0", + "MYAPP_RATELIMIT_RATE_LIMIT_ALGORITHM": "fixed_window", + } + + config = loader.load_rate_limit_config_from_env(env_vars) + print(f"Custom prefix: limit={config.limit}, algorithm={config.algorithm}") + return config + + +# ============================================================================= +# Example 7: Validation and error handling +# ============================================================================= + + +def example_validation() -> None: + """Demonstrate configuration validation and error handling.""" + loader = ConfigLoader() + + # Example 1: Invalid algorithm value + try: + loader.load_rate_limit_config_from_env( + { + "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", + "FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "invalid_algo", + } + ) + except ConfigurationError as e: + print(f"Validation error (invalid algorithm): {e}") + + # Example 2: Invalid numeric value + try: + loader.load_rate_limit_config_from_env( + {"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "not_a_number"} + ) + except ConfigurationError as e: + print(f"Validation error (invalid number): {e}") + + # Example 3: Missing required field + try: + loader.load_rate_limit_config_from_env( + {"FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0"} + ) + except ConfigurationError as e: + print(f"Validation error (missing limit): {e}") + + # Example 4: Non-loadable field (callables can't be loaded from config) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"limit": 100, "key_extractor": "some_function"}, f) + json_path = f.name + + try: + loader.load_rate_limit_config_from_json(json_path) + except ConfigurationError as e: + print(f"Validation error (non-loadable field): {e}") + finally: + Path(json_path).unlink() + + +# ============================================================================= +# Example 8: Environment-based configuration (dev/staging/prod) +# ============================================================================= + + +def example_environment_based_config() -> RateLimitConfig: + """Load different configurations based on environment. + + This pattern is useful for having different rate limits in + development, staging, and production environments. + """ + env = os.getenv("APP_ENV", "development") + + # In a real app, these would be actual files + configs = { + "development": {"limit": 1000, "window_size": 60.0, "skip_on_error": True}, + "staging": {"limit": 500, "window_size": 60.0, "skip_on_error": True}, + "production": {"limit": 100, "window_size": 60.0, "skip_on_error": False}, + } + + config_data = configs.get(env, configs["development"]) + + # Create temp file with the config + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(config_data, f) + config_path = f.name + + try: + config = load_rate_limit_config(config_path) + print(f"Environment '{env}': limit={config.limit}") + return config + finally: + Path(config_path).unlink() + + +# ============================================================================= +# Example 9: Full FastAPI application with config loading +# ============================================================================= + + +def create_app_with_config() -> FastAPI: + """Create a FastAPI app with configuration loaded from files.""" + # In production, load from actual config files: + # global_config = load_global_config("config/global.json") + # rate_config = load_rate_limit_config("config/rate_limit.json") + + # For this example, create inline configs + global_config = GlobalConfig( + enabled=True, + default_limit=100, + default_window_size=60.0, + exempt_paths={"/health", "/docs", "/openapi.json"}, + ) + + backend = MemoryBackend() + limiter = RateLimiter(backend, config=global_config) + + @asynccontextmanager + async def lifespan(_: FastAPI): + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + app = FastAPI( + title="Config Loader Example", + description="Rate limiting with external configuration", + lifespan=lifespan, + ) + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(_: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": exc.message, + "retry_after": exc.retry_after, + }, + ) + + @app.get("/") + @rate_limit(limit=10, window_size=60) + async def root(_: Request) -> dict[str, str]: + return {"message": "Hello from config-loaded app!"} + + @app.get("/health") + async def health() -> dict[str, str]: + """Health check - exempt from rate limiting.""" + return {"status": "healthy"} + + @app.get("/api/data") + @rate_limit(limit=50, window_size=60) + async def get_data(_: Request) -> dict[str, str]: + return {"data": "Some API data"} + + return app + + +# Create the app instance +app = create_app_with_config() + + +# ============================================================================= +# Run all examples +# ============================================================================= + + +def run_examples() -> None: + """Run all configuration loading examples.""" + print("=" * 60) + print("FastAPI Traffic - Configuration Loader Examples") + print("=" * 60) + + print("\n1. Loading from environment variables:") + print("-" * 40) + example_env_variables() + + print("\n2. Loading GlobalConfig from environment:") + print("-" * 40) + example_global_config_env() + + print("\n3. Loading from .env file:") + print("-" * 40) + example_dotenv_file() + + print("\n4. Loading from JSON file:") + print("-" * 40) + example_json_file() + + print("\n5. Loading GlobalConfig from JSON:") + print("-" * 40) + example_global_config_json() + + print("\n6. Using custom environment prefix:") + print("-" * 40) + example_custom_prefix() + + print("\n7. Validation and error handling:") + print("-" * 40) + example_validation() + + print("\n8. Environment-based configuration:") + print("-" * 40) + example_environment_based_config() + + print("\n" + "=" * 60) + print("All examples completed!") + print("=" * 60) + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "--demo": + # Run the demo examples + run_examples() + else: + # Run the FastAPI app + import uvicorn + + print("Starting FastAPI app with config loader...") + print("Run with --demo flag to see configuration examples") + uvicorn.run(app, host="127.0.0.1", port=8011) diff --git a/examples/README.md b/examples/README.md index b896877..1ffc44c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,13 +5,17 @@ This directory contains comprehensive examples demonstrating how to use the `fas ## Basic Examples ### 01_quickstart.py + Minimal setup to get rate limiting working. Start here if you're new to the library. + - Basic backend and limiter setup - Exception handler for rate limit errors - Simple decorator usage ### 02_algorithms.py + Demonstrates all available rate limiting algorithms: + - **Fixed Window** - Simple, resets at fixed intervals - **Sliding Window** - Most precise, stores timestamps - **Sliding Window Counter** - Balance of precision and efficiency (default) @@ -19,13 +23,17 @@ Demonstrates all available rate limiting algorithms: - **Leaky Bucket** - Smooths out traffic ### 03_backends.py + Shows different storage backends: + - **MemoryBackend** - Fast, ephemeral (default) - **SQLiteBackend** - Persistent, single-instance - **RedisBackend** - Distributed, multi-instance ### 04_key_extractors.py + Custom key extractors for different rate limiting strategies: + - Rate limit by IP address (default) - Rate limit by API key - Rate limit by user ID @@ -34,7 +42,9 @@ Custom key extractors for different rate limiting strategies: - Composite keys (user + action) ### 05_middleware.py + Middleware-based rate limiting for global protection: + - Basic middleware setup - Custom configuration options - Path and IP exemptions @@ -43,35 +53,45 @@ Middleware-based rate limiting for global protection: ## Advanced Examples ### 06_dependency_injection.py + Using FastAPI's dependency injection system: + - Basic rate limit dependency - Tier-based rate limiting - Combining multiple rate limits - Conditional exemptions ### 07_redis_distributed.py + Redis backend for distributed deployments: + - Multi-instance rate limiting - Shared counters across nodes - Health checks and statistics - Fallback to memory backend ### 08_tiered_api.py + Production-ready tiered API example: + - Free, Starter, Pro, Enterprise tiers - Different limits per tier - Feature gating based on tier - API key validation ### 09_custom_responses.py + Customizing rate limit responses: + - Custom JSON error responses - Logging/monitoring callbacks - Different response formats (JSON, HTML, plain text) - Rate limit headers ### 10_advanced_patterns.py + Real-world patterns and use cases: + - **Cost-based limiting** - Different operations cost different amounts - **Priority exemptions** - Premium users exempt from limits - **Resource-based limiting** - Limit by resource ID + user @@ -81,6 +101,23 @@ Real-world patterns and use cases: - **Time-of-day limits** - Peak vs off-peak hours - **Cascading limits** - Per-second, per-minute, per-hour +### 11_config_loader.py + +Loading configuration from external files: + +- **Environment variables** - Load from `FASTAPI_TRAFFIC_*` env vars +- **.env files** - Load from dotenv files for local development +- **JSON files** - Load from JSON for structured configuration +- **Custom prefixes** - Use custom env var prefixes +- **Validation** - Automatic type validation and error handling +- **Environment-based config** - Different configs for dev/staging/prod + +Run with `--demo` flag to see all configuration examples: + +```bash +python examples/11_config_loader.py --demo +``` + ## Running Examples Each example is a standalone FastAPI application. Run with: diff --git a/fastapi_traffic/__init__.py b/fastapi_traffic/__init__.py index 7bda8ae..c3c5d1e 100644 --- a/fastapi_traffic/__init__.py +++ b/fastapi_traffic/__init__.py @@ -1,30 +1,43 @@ """FastAPI Traffic - Production-grade rate limiting for FastAPI.""" -from fastapi_traffic.core.decorator import rate_limit -from fastapi_traffic.core.limiter import RateLimiter -from fastapi_traffic.core.config import RateLimitConfig -from fastapi_traffic.core.algorithms import Algorithm from fastapi_traffic.backends.base import Backend from fastapi_traffic.backends.memory import MemoryBackend from fastapi_traffic.backends.sqlite import SQLiteBackend +from fastapi_traffic.core.algorithms import Algorithm +from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig +from fastapi_traffic.core.config_loader import ( + ConfigLoader, + load_global_config, + load_global_config_from_env, + load_rate_limit_config, + load_rate_limit_config_from_env, +) +from fastapi_traffic.core.decorator import rate_limit +from fastapi_traffic.core.limiter import RateLimiter from fastapi_traffic.exceptions import ( - RateLimitExceeded, BackendError, ConfigurationError, + RateLimitExceeded, ) __version__ = "0.1.0" __all__ = [ - "rate_limit", - "RateLimiter", - "RateLimitConfig", "Algorithm", "Backend", - "MemoryBackend", - "SQLiteBackend", - "RateLimitExceeded", "BackendError", + "ConfigLoader", "ConfigurationError", + "GlobalConfig", + "MemoryBackend", + "RateLimitConfig", + "RateLimitExceeded", + "RateLimiter", + "SQLiteBackend", + "load_global_config", + "load_global_config_from_env", + "load_rate_limit_config", + "load_rate_limit_config_from_env", + "rate_limit", ] # Optional Redis backend diff --git a/fastapi_traffic/core/__init__.py b/fastapi_traffic/core/__init__.py index 0d912bd..fa17274 100644 --- a/fastapi_traffic/core/__init__.py +++ b/fastapi_traffic/core/__init__.py @@ -1,16 +1,29 @@ """Core rate limiting components.""" from fastapi_traffic.core.algorithms import Algorithm -from fastapi_traffic.core.config import RateLimitConfig +from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig +from fastapi_traffic.core.config_loader import ( + ConfigLoader, + load_global_config, + load_global_config_from_env, + load_rate_limit_config, + load_rate_limit_config_from_env, +) from fastapi_traffic.core.decorator import rate_limit from fastapi_traffic.core.limiter import RateLimiter from fastapi_traffic.core.models import RateLimitInfo, RateLimitResult __all__ = [ "Algorithm", + "ConfigLoader", + "GlobalConfig", "RateLimitConfig", - "rate_limit", - "RateLimiter", "RateLimitInfo", "RateLimitResult", + "RateLimiter", + "load_global_config", + "load_global_config_from_env", + "load_rate_limit_config", + "load_rate_limit_config_from_env", + "rate_limit", ] diff --git a/fastapi_traffic/core/config_loader.py b/fastapi_traffic/core/config_loader.py new file mode 100644 index 0000000..afe4ef6 --- /dev/null +++ b/fastapi_traffic/core/config_loader.py @@ -0,0 +1,532 @@ +"""Configuration loader for rate limiting settings from .env and .json files.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar + +from fastapi_traffic.core.algorithms import Algorithm +from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig +from fastapi_traffic.exceptions import ConfigurationError + +if TYPE_CHECKING: + from collections.abc import Mapping + +T = TypeVar("T", RateLimitConfig, GlobalConfig) + +# Environment variable prefix for config values +ENV_PREFIX = "FASTAPI_TRAFFIC_" + +# Mapping of config field names to their types for validation +_RATE_LIMIT_FIELD_TYPES: dict[str, type[Any]] = { + "limit": int, + "window_size": float, + "algorithm": Algorithm, + "key_prefix": str, + "burst_size": int, + "include_headers": bool, + "error_message": str, + "status_code": int, + "skip_on_error": bool, + "cost": int, +} + +_GLOBAL_FIELD_TYPES: dict[str, type[Any]] = { + "enabled": bool, + "default_limit": int, + "default_window_size": float, + "default_algorithm": Algorithm, + "key_prefix": str, + "include_headers": bool, + "error_message": str, + "status_code": int, + "skip_on_error": bool, + "exempt_ips": set, + "exempt_paths": set, + "headers_prefix": str, +} + +# Fields that cannot be loaded from config files (callables, complex objects) +_NON_LOADABLE_FIELDS: frozenset[str] = frozenset({ + "key_extractor", + "exempt_when", + "on_blocked", + "backend", +}) + + +class ConfigLoader: + """Loader for rate limiting configuration from various sources. + + Supports loading configuration from: + - Environment variables (with FASTAPI_TRAFFIC_ prefix) + - .env files + - JSON files + + Example usage: + >>> loader = ConfigLoader() + >>> global_config = loader.load_global_config_from_env() + >>> rate_config = loader.load_rate_limit_config_from_json("config.json") + """ + + __slots__ = ("_env_prefix",) + + def __init__(self, env_prefix: str = ENV_PREFIX) -> None: + """Initialize the config loader. + + Args: + env_prefix: Prefix for environment variables. Defaults to "FASTAPI_TRAFFIC_". + """ + self._env_prefix = env_prefix + + def _parse_value(self, value: str, target_type: type[Any]) -> Any: + """Parse a string value to the target type. + + Args: + value: The string value to parse. + target_type: The target type to convert to. + + Returns: + The parsed value. + + Raises: + ConfigurationError: If the value cannot be parsed. + """ + try: + if target_type is bool: + return value.lower() in ("true", "1", "yes", "on") + if target_type is int: + return int(value) + if target_type is float: + return float(value) + if target_type is str: + return value + if target_type is Algorithm: + return Algorithm(value.lower()) + if target_type is set: + # Parse comma-separated values + if not value.strip(): + return set() + return {item.strip() for item in value.split(",") if item.strip()} + except (ValueError, KeyError) as e: + msg = f"Cannot parse value '{value}' as {target_type.__name__}: {e}" + raise ConfigurationError(msg) from e + + msg = f"Unsupported type: {target_type}" + raise ConfigurationError(msg) + + def _validate_and_convert( + self, + data: Mapping[str, Any], + field_types: dict[str, type[Any]], + ) -> dict[str, Any]: + """Validate and convert configuration data. + + Args: + data: Raw configuration data. + field_types: Mapping of field names to their expected types. + + Returns: + Validated and converted configuration dictionary. + + Raises: + ConfigurationError: If validation fails. + """ + result: dict[str, Any] = {} + + for key, value in data.items(): + if key in _NON_LOADABLE_FIELDS: + msg = f"Field '{key}' cannot be loaded from configuration files" + raise ConfigurationError(msg) + + if key not in field_types: + msg = f"Unknown configuration field: '{key}'" + raise ConfigurationError(msg) + + target_type = field_types[key] + + if isinstance(value, str): + result[key] = self._parse_value(value, target_type) + elif target_type is set and isinstance(value, list): + result[key] = set(value) + elif target_type is Algorithm and isinstance(value, str): + result[key] = Algorithm(value.lower()) + elif isinstance(value, target_type): + result[key] = value + elif target_type is float and isinstance(value, int): + result[key] = float(value) + else: + msg = f"Invalid type for '{key}': expected {target_type.__name__}, got {type(value).__name__}" + raise ConfigurationError(msg) + + return result + + def _load_dotenv_file(self, file_path: Path) -> dict[str, str]: + """Load environment variables from a .env file. + + Args: + file_path: Path to the .env file. + + Returns: + Dictionary of environment variable names to values. + + Raises: + ConfigurationError: If the file cannot be read or parsed. + """ + if not file_path.exists(): + msg = f"Configuration file not found: {file_path}" + raise ConfigurationError(msg) + + env_vars: dict[str, str] = {} + + try: + with file_path.open(encoding="utf-8") as f: + for line_num, line in enumerate(f, start=1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + + # Parse key=value pairs + if "=" not in line: + msg = f"Invalid line {line_num} in {file_path}: missing '='" + raise ConfigurationError(msg) + + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + + # Remove surrounding quotes if present + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + + env_vars[key] = value + + except OSError as e: + msg = f"Failed to read configuration file {file_path}: {e}" + raise ConfigurationError(msg) from e + + return env_vars + + def _load_json_file(self, file_path: Path) -> dict[str, Any]: + """Load configuration from a JSON file. + + Args: + file_path: Path to the JSON file. + + Returns: + Configuration dictionary. + + Raises: + ConfigurationError: If the file cannot be read or parsed. + """ + if not file_path.exists(): + msg = f"Configuration file not found: {file_path}" + raise ConfigurationError(msg) + + try: + with file_path.open(encoding="utf-8") as f: + data: dict[str, Any] = json.load(f) + except json.JSONDecodeError as e: + msg = f"Invalid JSON in {file_path}: {e}" + raise ConfigurationError(msg) from e + except OSError as e: + msg = f"Failed to read configuration file {file_path}: {e}" + raise ConfigurationError(msg) from e + + return data + + def _extract_env_config( + self, + prefix: str, + field_types: dict[str, type[Any]], + env_source: Mapping[str, str] | None = None, + ) -> dict[str, str]: + """Extract configuration from environment variables. + + Args: + prefix: The prefix to look for (e.g., "RATE_LIMIT_" or "GLOBAL_"). + field_types: Mapping of field names to their expected types. + env_source: Optional source of environment variables. Defaults to os.environ. + + Returns: + Dictionary of field names to their string values. + """ + source = env_source if env_source is not None else os.environ + full_prefix = f"{self._env_prefix}{prefix}" + result: dict[str, str] = {} + + for key, value in source.items(): + if key.startswith(full_prefix): + field_name = key[len(full_prefix):].lower() + if field_name in field_types: + result[field_name] = value + + return result + + def load_rate_limit_config_from_env( + self, + env_source: Mapping[str, str] | None = None, + **overrides: Any, + ) -> RateLimitConfig: + """Load RateLimitConfig from environment variables. + + Environment variables should be prefixed with FASTAPI_TRAFFIC_RATE_LIMIT_ + (e.g., FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100). + + Args: + env_source: Optional source of environment variables. Defaults to os.environ. + **overrides: Additional values to override loaded config. + + Returns: + Configured RateLimitConfig instance. + + Raises: + ConfigurationError: If configuration is invalid. + """ + raw_config = self._extract_env_config( + "RATE_LIMIT_", _RATE_LIMIT_FIELD_TYPES, env_source + ) + config_dict = self._validate_and_convert(raw_config, _RATE_LIMIT_FIELD_TYPES) + + # Apply overrides + for key, value in overrides.items(): + if key in _NON_LOADABLE_FIELDS or key in _RATE_LIMIT_FIELD_TYPES: + config_dict[key] = value + + # Ensure required field 'limit' is present + if "limit" not in config_dict: + msg = "Required field 'limit' not found in environment configuration" + raise ConfigurationError(msg) + + return RateLimitConfig(**config_dict) + + def load_rate_limit_config_from_dotenv( + self, + file_path: str | Path, + **overrides: Any, + ) -> RateLimitConfig: + """Load RateLimitConfig from a .env file. + + Args: + file_path: Path to the .env file. + **overrides: Additional values to override loaded config. + + Returns: + Configured RateLimitConfig instance. + + Raises: + ConfigurationError: If configuration is invalid. + """ + path = Path(file_path) + env_vars = self._load_dotenv_file(path) + return self.load_rate_limit_config_from_env(env_vars, **overrides) + + def load_rate_limit_config_from_json( + self, + file_path: str | Path, + **overrides: Any, + ) -> RateLimitConfig: + """Load RateLimitConfig from a JSON file. + + Args: + file_path: Path to the JSON file. + **overrides: Additional values to override loaded config. + + Returns: + Configured RateLimitConfig instance. + + Raises: + ConfigurationError: If configuration is invalid. + """ + path = Path(file_path) + raw_config = self._load_json_file(path) + config_dict = self._validate_and_convert(raw_config, _RATE_LIMIT_FIELD_TYPES) + + # Apply overrides + for key, value in overrides.items(): + if key in _NON_LOADABLE_FIELDS or key in _RATE_LIMIT_FIELD_TYPES: + config_dict[key] = value + + # Ensure required field 'limit' is present + if "limit" not in config_dict: + msg = "Required field 'limit' not found in JSON configuration" + raise ConfigurationError(msg) + + return RateLimitConfig(**config_dict) + + def load_global_config_from_env( + self, + env_source: Mapping[str, str] | None = None, + **overrides: Any, + ) -> GlobalConfig: + """Load GlobalConfig from environment variables. + + Environment variables should be prefixed with FASTAPI_TRAFFIC_GLOBAL_ + (e.g., FASTAPI_TRAFFIC_GLOBAL_ENABLED=true). + + Args: + env_source: Optional source of environment variables. Defaults to os.environ. + **overrides: Additional values to override loaded config. + + Returns: + Configured GlobalConfig instance. + + Raises: + ConfigurationError: If configuration is invalid. + """ + raw_config = self._extract_env_config( + "GLOBAL_", _GLOBAL_FIELD_TYPES, env_source + ) + config_dict = self._validate_and_convert(raw_config, _GLOBAL_FIELD_TYPES) + + # Apply overrides + for key, value in overrides.items(): + if key in _NON_LOADABLE_FIELDS or key in _GLOBAL_FIELD_TYPES: + config_dict[key] = value + + return GlobalConfig(**config_dict) + + def load_global_config_from_dotenv( + self, + file_path: str | Path, + **overrides: Any, + ) -> GlobalConfig: + """Load GlobalConfig from a .env file. + + Args: + file_path: Path to the .env file. + **overrides: Additional values to override loaded config. + + Returns: + Configured GlobalConfig instance. + + Raises: + ConfigurationError: If configuration is invalid. + """ + path = Path(file_path) + env_vars = self._load_dotenv_file(path) + return self.load_global_config_from_env(env_vars, **overrides) + + def load_global_config_from_json( + self, + file_path: str | Path, + **overrides: Any, + ) -> GlobalConfig: + """Load GlobalConfig from a JSON file. + + Args: + file_path: Path to the JSON file. + **overrides: Additional values to override loaded config. + + Returns: + Configured GlobalConfig instance. + + Raises: + ConfigurationError: If configuration is invalid. + """ + path = Path(file_path) + raw_config = self._load_json_file(path) + config_dict = self._validate_and_convert(raw_config, _GLOBAL_FIELD_TYPES) + + # Apply overrides + for key, value in overrides.items(): + if key in _NON_LOADABLE_FIELDS or key in _GLOBAL_FIELD_TYPES: + config_dict[key] = value + + return GlobalConfig(**config_dict) + + +# Convenience functions for direct usage +_default_loader: ConfigLoader | None = None + + +def _get_default_loader() -> ConfigLoader: + """Get or create the default config loader.""" + global _default_loader + if _default_loader is None: + _default_loader = ConfigLoader() + return _default_loader + + +def load_rate_limit_config( + source: str | Path, + **overrides: Any, +) -> RateLimitConfig: + """Load RateLimitConfig from a file (auto-detects format). + + Args: + source: Path to configuration file (.env or .json). + **overrides: Additional values to override loaded config. + + Returns: + Configured RateLimitConfig instance. + + Raises: + ConfigurationError: If configuration is invalid or format unknown. + """ + loader = _get_default_loader() + path = Path(source) + + if path.suffix.lower() == ".json": + return loader.load_rate_limit_config_from_json(path, **overrides) + if path.suffix.lower() in (".env", "") or path.name.startswith(".env"): + return loader.load_rate_limit_config_from_dotenv(path, **overrides) + + msg = f"Unknown configuration file format: {path.suffix}" + raise ConfigurationError(msg) + + +def load_global_config( + source: str | Path, + **overrides: Any, +) -> GlobalConfig: + """Load GlobalConfig from a file (auto-detects format). + + Args: + source: Path to configuration file (.env or .json). + **overrides: Additional values to override loaded config. + + Returns: + Configured GlobalConfig instance. + + Raises: + ConfigurationError: If configuration is invalid or format unknown. + """ + loader = _get_default_loader() + path = Path(source) + + if path.suffix.lower() == ".json": + return loader.load_global_config_from_json(path, **overrides) + if path.suffix.lower() in (".env", "") or path.name.startswith(".env"): + return loader.load_global_config_from_dotenv(path, **overrides) + + msg = f"Unknown configuration file format: {path.suffix}" + raise ConfigurationError(msg) + + +def load_rate_limit_config_from_env(**overrides: Any) -> RateLimitConfig: + """Load RateLimitConfig from environment variables. + + Args: + **overrides: Additional values to override loaded config. + + Returns: + Configured RateLimitConfig instance. + """ + return _get_default_loader().load_rate_limit_config_from_env(**overrides) + + +def load_global_config_from_env(**overrides: Any) -> GlobalConfig: + """Load GlobalConfig from environment variables. + + Args: + **overrides: Additional values to override loaded config. + + Returns: + Configured GlobalConfig instance. + """ + return _get_default_loader().load_global_config_from_env(**overrides) diff --git a/fastapi_traffic/core/limiter.py b/fastapi_traffic/core/limiter.py index 31f4e43..7d5dd97 100644 --- a/fastapi_traffic/core/limiter.py +++ b/fastapi_traffic/core/limiter.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) class RateLimiter: """Main rate limiter class that manages rate limiting logic.""" - __slots__ = ("_config", "_backend", "_algorithms", "_initialized") + __slots__ = ("_algorithms", "_backend", "_config", "_initialized") def __init__( self, @@ -96,15 +96,14 @@ class RateLimiter: identifier: str | None = None, ) -> str: """Build the rate limit key for a request.""" - if identifier: - client_id = identifier - else: - client_id = config.key_extractor(request) + client_id = identifier or config.key_extractor(request) path = request.url.path method = request.method - return f"{self._config.key_prefix}:{config.key_prefix}:{method}:{path}:{client_id}" + return ( + f"{self._config.key_prefix}:{config.key_prefix}:{method}:{path}:{client_id}" + ) def _is_exempt(self, request: Request, config: RateLimitConfig) -> bool: """Check if the request is exempt from rate limiting.""" @@ -118,10 +117,7 @@ class RateLimiter: if client_ip in self._config.exempt_ips: return True - if request.url.path in self._config.exempt_paths: - return True - - return False + return request.url.path in self._config.exempt_paths async def check( self, diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py new file mode 100644 index 0000000..bacd9ae --- /dev/null +++ b/tests/test_config_loader.py @@ -0,0 +1,599 @@ +"""Tests for configuration loader.""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from fastapi_traffic.core.algorithms import Algorithm +from fastapi_traffic.core.config import GlobalConfig +from fastapi_traffic.core.config_loader import ( + ConfigLoader, + load_global_config, + load_global_config_from_env, + load_rate_limit_config, + load_rate_limit_config_from_env, +) +from fastapi_traffic.exceptions import ConfigurationError + +if TYPE_CHECKING: + from collections.abc import Generator + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory for test files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def loader() -> ConfigLoader: + """Create a ConfigLoader instance.""" + return ConfigLoader() + + +class TestConfigLoaderEnv: + """Tests for loading configuration from environment variables.""" + + def test_load_rate_limit_config_from_env(self, loader: ConfigLoader) -> None: + """Test loading RateLimitConfig from environment variables.""" + env_vars = { + "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", + "FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0", + "FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "token_bucket", + "FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX": "test", + "FASTAPI_TRAFFIC_RATE_LIMIT_INCLUDE_HEADERS": "true", + "FASTAPI_TRAFFIC_RATE_LIMIT_STATUS_CODE": "429", + "FASTAPI_TRAFFIC_RATE_LIMIT_SKIP_ON_ERROR": "false", + "FASTAPI_TRAFFIC_RATE_LIMIT_COST": "1", + } + + config = loader.load_rate_limit_config_from_env(env_vars) + + assert config.limit == 100 + assert config.window_size == 60.0 + assert config.algorithm == Algorithm.TOKEN_BUCKET + assert config.key_prefix == "test" + assert config.include_headers is True + assert config.status_code == 429 + assert config.skip_on_error is False + assert config.cost == 1 + + def test_load_rate_limit_config_from_env_with_burst_size( + self, loader: ConfigLoader + ) -> None: + """Test loading RateLimitConfig with burst_size.""" + env_vars = { + "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "50", + "FASTAPI_TRAFFIC_RATE_LIMIT_BURST_SIZE": "100", + } + + config = loader.load_rate_limit_config_from_env(env_vars) + + assert config.limit == 50 + assert config.burst_size == 100 + + def test_load_rate_limit_config_from_env_missing_limit( + self, loader: ConfigLoader + ) -> None: + """Test that missing 'limit' field raises ConfigurationError.""" + env_vars = { + "FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0", + } + + with pytest.raises(ConfigurationError, match="Required field 'limit'"): + loader.load_rate_limit_config_from_env(env_vars) + + def test_load_rate_limit_config_from_env_with_overrides( + self, loader: ConfigLoader + ) -> None: + """Test loading with overrides.""" + env_vars = { + "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", + } + + config = loader.load_rate_limit_config_from_env( + env_vars, window_size=120.0, error_message="Custom error" + ) + + assert config.limit == 100 + assert config.window_size == 120.0 + assert config.error_message == "Custom error" + + def test_load_global_config_from_env(self, loader: ConfigLoader) -> None: + """Test loading GlobalConfig from environment variables.""" + env_vars = { + "FASTAPI_TRAFFIC_GLOBAL_ENABLED": "true", + "FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT": "200", + "FASTAPI_TRAFFIC_GLOBAL_DEFAULT_WINDOW_SIZE": "120.0", + "FASTAPI_TRAFFIC_GLOBAL_DEFAULT_ALGORITHM": "fixed_window", + "FASTAPI_TRAFFIC_GLOBAL_KEY_PREFIX": "global_test", + "FASTAPI_TRAFFIC_GLOBAL_INCLUDE_HEADERS": "false", + "FASTAPI_TRAFFIC_GLOBAL_STATUS_CODE": "503", + "FASTAPI_TRAFFIC_GLOBAL_SKIP_ON_ERROR": "true", + "FASTAPI_TRAFFIC_GLOBAL_HEADERS_PREFIX": "X-Custom", + } + + config = loader.load_global_config_from_env(env_vars) + + assert config.enabled is True + assert config.default_limit == 200 + assert config.default_window_size == 120.0 + assert config.default_algorithm == Algorithm.FIXED_WINDOW + assert config.key_prefix == "global_test" + assert config.include_headers is False + assert config.status_code == 503 + assert config.skip_on_error is True + assert config.headers_prefix == "X-Custom" + + def test_load_global_config_from_env_with_sets(self, loader: ConfigLoader) -> None: + """Test loading GlobalConfig with set fields.""" + env_vars = { + "FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS": "127.0.0.1, 192.168.1.1, 10.0.0.1", + "FASTAPI_TRAFFIC_GLOBAL_EXEMPT_PATHS": "/health, /metrics", + } + + config = loader.load_global_config_from_env(env_vars) + + assert config.exempt_ips == {"127.0.0.1", "192.168.1.1", "10.0.0.1"} + assert config.exempt_paths == {"/health", "/metrics"} + + def test_load_global_config_from_env_empty_sets( + self, loader: ConfigLoader + ) -> None: + """Test loading GlobalConfig with empty set fields.""" + env_vars = { + "FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS": "", + "FASTAPI_TRAFFIC_GLOBAL_EXEMPT_PATHS": "", + } + + config = loader.load_global_config_from_env(env_vars) + + assert config.exempt_ips == set() + assert config.exempt_paths == set() + + def test_load_global_config_defaults(self, loader: ConfigLoader) -> None: + """Test that GlobalConfig uses defaults when no env vars set.""" + config = loader.load_global_config_from_env({}) + + assert config.enabled is True + assert config.default_limit == 100 + assert config.default_window_size == 60.0 + + +class TestConfigLoaderDotenv: + """Tests for loading configuration from .env files.""" + + def test_load_rate_limit_config_from_dotenv( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test loading RateLimitConfig from .env file.""" + env_file = temp_dir / ".env" + env_file.write_text( + """ +# Rate limit configuration +FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=150 +FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=30.0 +FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=sliding_window +FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=api +""" + ) + + config = loader.load_rate_limit_config_from_dotenv(env_file) + + assert config.limit == 150 + assert config.window_size == 30.0 + assert config.algorithm == Algorithm.SLIDING_WINDOW + assert config.key_prefix == "api" + + def test_load_rate_limit_config_from_dotenv_with_quotes( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test loading config with quoted values.""" + env_file = temp_dir / ".env" + env_file.write_text( + """ +FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100 +FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Custom error message" +FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX='quoted_prefix' +""" + ) + + config = loader.load_rate_limit_config_from_dotenv(env_file) + + assert config.limit == 100 + assert config.error_message == "Custom error message" + assert config.key_prefix == "quoted_prefix" + + def test_load_global_config_from_dotenv( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test loading GlobalConfig from .env file.""" + env_file = temp_dir / ".env" + env_file.write_text( + """ +FASTAPI_TRAFFIC_GLOBAL_ENABLED=false +FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=500 +FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS=10.0.0.1,10.0.0.2 +""" + ) + + config = loader.load_global_config_from_dotenv(env_file) + + assert config.enabled is False + assert config.default_limit == 500 + assert config.exempt_ips == {"10.0.0.1", "10.0.0.2"} + + def test_load_from_dotenv_file_not_found( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test that missing file raises ConfigurationError.""" + with pytest.raises(ConfigurationError, match="not found"): + loader.load_rate_limit_config_from_dotenv(temp_dir / "nonexistent.env") + + def test_load_from_dotenv_invalid_line( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test that invalid line format raises ConfigurationError.""" + env_file = temp_dir / ".env" + env_file.write_text( + """ +FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100 +invalid line without equals +""" + ) + + with pytest.raises(ConfigurationError, match="missing '='"): + loader.load_rate_limit_config_from_dotenv(env_file) + + +class TestConfigLoaderJson: + """Tests for loading configuration from JSON files.""" + + def test_load_rate_limit_config_from_json( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test loading RateLimitConfig from JSON file.""" + json_file = temp_dir / "config.json" + config_data = { + "limit": 200, + "window_size": 45.0, + "algorithm": "leaky_bucket", + "key_prefix": "json_test", + "include_headers": False, + "status_code": 429, + "cost": 2, + } + json_file.write_text(json.dumps(config_data)) + + config = loader.load_rate_limit_config_from_json(json_file) + + assert config.limit == 200 + assert config.window_size == 45.0 + assert config.algorithm == Algorithm.LEAKY_BUCKET + assert config.key_prefix == "json_test" + assert config.include_headers is False + assert config.status_code == 429 + assert config.cost == 2 + + def test_load_rate_limit_config_from_json_with_int_window( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test that integer window_size is converted to float.""" + json_file = temp_dir / "config.json" + config_data = {"limit": 100, "window_size": 60} + json_file.write_text(json.dumps(config_data)) + + config = loader.load_rate_limit_config_from_json(json_file) + + assert config.window_size == 60.0 + assert isinstance(config.window_size, float) + + def test_load_global_config_from_json( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test loading GlobalConfig from JSON file.""" + json_file = temp_dir / "config.json" + config_data = { + "enabled": True, + "default_limit": 1000, + "default_window_size": 300.0, + "default_algorithm": "sliding_window_counter", + "exempt_ips": ["127.0.0.1", "::1"], + "exempt_paths": ["/health", "/ready", "/metrics"], + } + json_file.write_text(json.dumps(config_data)) + + config = loader.load_global_config_from_json(json_file) + + assert config.enabled is True + assert config.default_limit == 1000 + assert config.default_window_size == 300.0 + assert config.default_algorithm == Algorithm.SLIDING_WINDOW_COUNTER + assert config.exempt_ips == {"127.0.0.1", "::1"} + assert config.exempt_paths == {"/health", "/ready", "/metrics"} + + def test_load_from_json_file_not_found( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test that missing file raises ConfigurationError.""" + with pytest.raises(ConfigurationError, match="not found"): + loader.load_rate_limit_config_from_json(temp_dir / "nonexistent.json") + + def test_load_from_json_invalid_json( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test that invalid JSON raises ConfigurationError.""" + json_file = temp_dir / "config.json" + json_file.write_text("{ invalid json }") + + with pytest.raises(ConfigurationError, match="Invalid JSON"): + loader.load_rate_limit_config_from_json(json_file) + + def test_load_from_json_non_object_root( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test that non-object JSON root raises ConfigurationError.""" + json_file = temp_dir / "config.json" + json_file.write_text("[1, 2, 3]") + + with pytest.raises(ConfigurationError, match="JSON root must be an object"): + loader.load_rate_limit_config_from_json(json_file) + + def test_load_from_json_missing_limit( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test that missing 'limit' field raises ConfigurationError.""" + json_file = temp_dir / "config.json" + config_data = {"window_size": 60.0} + json_file.write_text(json.dumps(config_data)) + + with pytest.raises(ConfigurationError, match="Required field 'limit'"): + loader.load_rate_limit_config_from_json(json_file) + + +class TestConfigLoaderValidation: + """Tests for configuration validation.""" + + def test_invalid_algorithm_value(self, loader: ConfigLoader) -> None: + """Test that invalid algorithm raises ConfigurationError.""" + env_vars = { + "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", + "FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "invalid_algorithm", + } + + with pytest.raises(ConfigurationError, match="Cannot parse value"): + loader.load_rate_limit_config_from_env(env_vars) + + def test_invalid_int_value(self, loader: ConfigLoader) -> None: + """Test that invalid integer raises ConfigurationError.""" + env_vars = { + "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "not_a_number", + } + + with pytest.raises(ConfigurationError, match="Cannot parse value"): + loader.load_rate_limit_config_from_env(env_vars) + + def test_invalid_float_value(self, loader: ConfigLoader) -> None: + """Test that invalid float raises ConfigurationError.""" + env_vars = { + "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", + "FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "not_a_float", + } + + with pytest.raises(ConfigurationError, match="Cannot parse value"): + loader.load_rate_limit_config_from_env(env_vars) + + def test_unknown_field(self, loader: ConfigLoader, temp_dir: Path) -> None: + """Test that unknown field raises ConfigurationError.""" + json_file = temp_dir / "config.json" + config_data = {"limit": 100, "unknown_field": "value"} + json_file.write_text(json.dumps(config_data)) + + with pytest.raises(ConfigurationError, match="Unknown configuration field"): + loader.load_rate_limit_config_from_json(json_file) + + def test_non_loadable_field(self, loader: ConfigLoader, temp_dir: Path) -> None: + """Test that non-loadable field raises ConfigurationError.""" + json_file = temp_dir / "config.json" + config_data = {"limit": 100, "key_extractor": "some_function"} + json_file.write_text(json.dumps(config_data)) + + with pytest.raises(ConfigurationError, match="cannot be loaded"): + loader.load_rate_limit_config_from_json(json_file) + + def test_invalid_type_in_json(self, loader: ConfigLoader, temp_dir: Path) -> None: + """Test that invalid type in JSON raises ConfigurationError.""" + json_file = temp_dir / "config.json" + config_data = {"limit": "not_an_int"} + json_file.write_text(json.dumps(config_data)) + + with pytest.raises(ConfigurationError, match="Cannot parse value"): + loader.load_rate_limit_config_from_json(json_file) + + def test_bool_parsing_variations(self, loader: ConfigLoader) -> None: + """Test various boolean string representations.""" + for true_val in ["true", "True", "TRUE", "1", "yes", "Yes", "on", "ON"]: + env_vars = { + "FASTAPI_TRAFFIC_GLOBAL_ENABLED": true_val, + } + config = loader.load_global_config_from_env(env_vars) + assert config.enabled is True, f"Failed for value: {true_val}" + + for false_val in ["false", "False", "FALSE", "0", "no", "No", "off", "OFF"]: + env_vars = { + "FASTAPI_TRAFFIC_GLOBAL_ENABLED": false_val, + } + config = loader.load_global_config_from_env(env_vars) + assert config.enabled is False, f"Failed for value: {false_val}" + + +class TestConvenienceFunctions: + """Tests for convenience functions.""" + + def test_load_rate_limit_config_json(self, temp_dir: Path) -> None: + """Test load_rate_limit_config with JSON file.""" + json_file = temp_dir / "config.json" + config_data = {"limit": 100} + json_file.write_text(json.dumps(config_data)) + + config = load_rate_limit_config(json_file) + + assert config.limit == 100 + + def test_load_rate_limit_config_dotenv(self, temp_dir: Path) -> None: + """Test load_rate_limit_config with .env file.""" + env_file = temp_dir / ".env" + env_file.write_text("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=200") + + config = load_rate_limit_config(env_file) + + assert config.limit == 200 + + def test_load_rate_limit_config_env_suffix(self, temp_dir: Path) -> None: + """Test load_rate_limit_config with .env suffix.""" + env_file = temp_dir / "custom.env" + env_file.write_text("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=300") + + config = load_rate_limit_config(env_file) + + assert config.limit == 300 + + def test_load_global_config_json(self, temp_dir: Path) -> None: + """Test load_global_config with JSON file.""" + json_file = temp_dir / "config.json" + config_data = {"default_limit": 500} + json_file.write_text(json.dumps(config_data)) + + config = load_global_config(json_file) + + assert config.default_limit == 500 + + def test_load_global_config_dotenv(self, temp_dir: Path) -> None: + """Test load_global_config with .env file.""" + env_file = temp_dir / ".env" + env_file.write_text("FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=600") + + config = load_global_config(env_file) + + assert config.default_limit == 600 + + def test_load_config_unknown_format(self, temp_dir: Path) -> None: + """Test that unknown file format raises ConfigurationError.""" + unknown_file = temp_dir / "config.yaml" + unknown_file.write_text("limit: 100") + + with pytest.raises(ConfigurationError, match="Unknown configuration file"): + load_rate_limit_config(unknown_file) + + def test_load_rate_limit_config_from_env_function(self) -> None: + """Test load_rate_limit_config_from_env convenience function.""" + # This will use defaults since no env vars are set + # We need to provide the limit as an override + config = load_rate_limit_config_from_env(limit=100) + + assert config.limit == 100 + + def test_load_global_config_from_env_function(self) -> None: + """Test load_global_config_from_env convenience function.""" + config = load_global_config_from_env() + + assert isinstance(config, GlobalConfig) + assert config.enabled is True + + +class TestCustomEnvPrefix: + """Tests for custom environment variable prefix.""" + + def test_custom_prefix(self) -> None: + """Test loading with custom environment prefix.""" + loader = ConfigLoader(env_prefix="CUSTOM_") + env_vars = { + "CUSTOM_RATE_LIMIT_LIMIT": "100", + "CUSTOM_RATE_LIMIT_WINDOW_SIZE": "30.0", + } + + config = loader.load_rate_limit_config_from_env(env_vars) + + assert config.limit == 100 + assert config.window_size == 30.0 + + def test_custom_prefix_global(self) -> None: + """Test loading GlobalConfig with custom prefix.""" + loader = ConfigLoader(env_prefix="MY_APP_") + env_vars = { + "MY_APP_GLOBAL_ENABLED": "false", + "MY_APP_GLOBAL_DEFAULT_LIMIT": "250", + } + + config = loader.load_global_config_from_env(env_vars) + + assert config.enabled is False + assert config.default_limit == 250 + + +class TestAllAlgorithms: + """Tests for all algorithm types.""" + + @pytest.mark.parametrize( + "algorithm_str,expected", + [ + ("token_bucket", Algorithm.TOKEN_BUCKET), + ("sliding_window", Algorithm.SLIDING_WINDOW), + ("fixed_window", Algorithm.FIXED_WINDOW), + ("leaky_bucket", Algorithm.LEAKY_BUCKET), + ("sliding_window_counter", Algorithm.SLIDING_WINDOW_COUNTER), + ("TOKEN_BUCKET", Algorithm.TOKEN_BUCKET), + ("SLIDING_WINDOW", Algorithm.SLIDING_WINDOW), + ], + ) + def test_algorithm_parsing( + self, loader: ConfigLoader, algorithm_str: str, expected: Algorithm + ) -> None: + """Test that all algorithm values are parsed correctly.""" + env_vars = { + "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", + "FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": algorithm_str, + } + + config = loader.load_rate_limit_config_from_env(env_vars) + + assert config.algorithm == expected + + +class TestDataclassValidation: + """Tests that dataclass validation still works after loading.""" + + def test_invalid_limit_value(self, loader: ConfigLoader, temp_dir: Path) -> None: + """Test that invalid limit value is caught by dataclass validation.""" + json_file = temp_dir / "config.json" + config_data = {"limit": 0} + json_file.write_text(json.dumps(config_data)) + + with pytest.raises(ValueError, match="limit must be positive"): + loader.load_rate_limit_config_from_json(json_file) + + def test_invalid_window_size_value( + self, loader: ConfigLoader, temp_dir: Path + ) -> None: + """Test that invalid window_size is caught by dataclass validation.""" + json_file = temp_dir / "config.json" + config_data = {"limit": 100, "window_size": -1.0} + json_file.write_text(json.dumps(config_data)) + + with pytest.raises(ValueError, match="window_size must be positive"): + loader.load_rate_limit_config_from_json(json_file) + + def test_invalid_cost_value(self, loader: ConfigLoader, temp_dir: Path) -> None: + """Test that invalid cost is caught by dataclass validation.""" + json_file = temp_dir / "config.json" + config_data = {"limit": 100, "cost": 0} + json_file.write_text(json.dumps(config_data)) + + with pytest.raises(ValueError, match="cost must be positive"): + loader.load_rate_limit_config_from_json(json_file)