diff --git a/logrotate.conf b/logrotate.conf new file mode 100644 index 00000000..9beafad0 --- /dev/null +++ b/logrotate.conf @@ -0,0 +1,81 @@ +# Logrotate Configuration for FastAPI Application Logs +# ===================================================== +# +# This configuration file manages log rotation for the FastAPI application. +# It should be placed in /etc/logrotate.d/ on Linux systems. +# +# Installation: +# sudo cp logrotate.conf /etc/logrotate.d/fastapi-app +# +# Test configuration: +# sudo logrotate -d /etc/logrotate.d/fastapi-app +# +# Force rotation: +# sudo logrotate -f /etc/logrotate.d/fastapi-app + +# API Server Logs +/var/log/fastapi/*.log /app/logs/*.log { + # Rotate logs daily + daily + + # Keep 14 days of logs + rotate 14 + + # Compress old logs with gzip + compress + + # Don't compress the most recent rotated file (allows apps to keep writing) + delaycompress + + # Don't error if log file is missing + missingok + + # Don't rotate if empty + notifempty + + # Create new log file with same permissions + create 0644 root root + + # Add date extension to rotated files + dateext + dateformat -%Y%m%d + + # Truncate original file after copying (for apps that don't handle SIGHUP) + copytruncate + + # Run scripts only once even if multiple logs match + sharedscripts + + # Optional: Notify application after rotation + # postrotate + # systemctl reload fastapi-app 2>/dev/null || true + # endscript +} + +# Access Logs (if using nginx reverse proxy) +/var/log/nginx/fastapi-access.log { + daily + rotate 30 + compress + delaycompress + missingok + notifempty + create 0644 www-data www-data + dateext + + postrotate + [ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid) + endscript +} + +# Error Logs (keep longer for debugging) +/var/log/fastapi/error.log { + weekly + rotate 52 + compress + delaycompress + missingok + notifempty + create 0644 root root + dateext +} diff --git a/src/app/core/config.py b/src/app/core/config.py index e67169a3..11ad132e 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -74,8 +74,7 @@ class FirstUserSettings(BaseSettings): ADMIN_PASSWORD: str = "!Ch4ng3Th1sP4ssW0rd!" -class TestSettings(BaseSettings): - ... +class TestSettings(BaseSettings): ... class RedisCacheSettings(BaseSettings): @@ -149,6 +148,45 @@ class CORSSettings(BaseSettings): CORS_HEADERS: list[str] = ["*"] +class LoggingSettings(BaseSettings): + """Settings for application logging configuration. + + Attributes + ---------- + LOG_LEVEL : str + Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Default: INFO + LOG_DIR : str + Directory for log files. Default: ./logs + LOG_FILE : str + Log file name. Default: api.log + LOG_MAX_BYTES : int + Maximum size of each log file in bytes before rotation. Default: 10MB + LOG_BACKUP_COUNT : int + Number of backup files to keep. Default: 5 + LOG_FORMAT : str + Log message format string. + LOG_DATE_FORMAT : str + Date format for log timestamps. + LOG_REQUEST_BODY : bool + Whether to log request bodies (use with caution in production). Default: False + LOG_RESPONSE_BODY : bool + Whether to log response bodies (use with caution in production). Default: False + LOG_EXCLUDE_PATHS : list[str] + Paths to exclude from request/response logging (e.g., health checks). + """ + + LOG_LEVEL: str = "INFO" + LOG_DIR: str = "./logs" + LOG_FILE: str = "api.log" + LOG_MAX_BYTES: int = 10485760 # 10MB + LOG_BACKUP_COUNT: int = 5 + LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + LOG_DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S" + LOG_REQUEST_BODY: bool = True + LOG_RESPONSE_BODY: bool = True + LOG_EXCLUDE_PATHS: list[str] = ["/health", "/metrics", "/favicon.ico"] + + class Settings( AppSettings, SQLiteSettings, @@ -164,6 +202,7 @@ class Settings( CRUDAdminSettings, EnvironmentSettings, CORSSettings, + LoggingSettings, ): model_config = SettingsConfigDict( env_file=os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", ".env"), diff --git a/src/app/core/logger.py b/src/app/core/logger.py index 91b35a10..8487e51d 100644 --- a/src/app/core/logger.py +++ b/src/app/core/logger.py @@ -1,20 +1,147 @@ +""" +Enhanced logging configuration with file rotation and configurable settings. + +This module provides a centralized logging setup that: +- Supports console and file logging +- Uses RotatingFileHandler for automatic log rotation +- Respects environment-based configuration via LoggingSettings +- Provides colored console output for development +""" + import logging import os +import sys from logging.handlers import RotatingFileHandler +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .config import LoggingSettings + + +# ANSI color codes for console output +class LogColors: + """ANSI color codes for log level highlighting.""" + + RESET = "\033[0m" + DEBUG = "\033[36m" # Cyan + INFO = "\033[32m" # Green + WARNING = "\033[33m" # Yellow + ERROR = "\033[31m" # Red + CRITICAL = "\033[35m" # Magenta + + +class ColoredFormatter(logging.Formatter): + """Custom formatter that adds colors to log levels for console output.""" + + LEVEL_COLORS = { + logging.DEBUG: LogColors.DEBUG, + logging.INFO: LogColors.INFO, + logging.WARNING: LogColors.WARNING, + logging.ERROR: LogColors.ERROR, + logging.CRITICAL: LogColors.CRITICAL, + } + + def format(self, record: logging.LogRecord) -> str: + # Add color to the level name + color = self.LEVEL_COLORS.get(record.levelno, LogColors.RESET) + record.levelname = f"{color}{record.levelname}{LogColors.RESET}" + return super().format(record) + + +def setup_logging(settings: "LoggingSettings") -> logging.Logger: + """Configure and return the root logger based on settings. + + Parameters + ---------- + settings : LoggingSettings + Logging configuration settings. + + Returns + ------- + logging.Logger + Configured root logger instance. + """ + # Create log directory if it doesn't exist + log_dir = settings.LOG_DIR + if not os.path.isabs(log_dir): + # Make relative paths relative to the app directory + log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), log_dir) + + os.makedirs(log_dir, exist_ok=True) + log_file_path = os.path.join(log_dir, settings.LOG_FILE) + + # Get numeric log level + log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) + + # Configure root logger + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + + # Remove existing handlers to avoid duplicates + root_logger.handlers.clear() + + # Console handler with colors + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + console_formatter = ColoredFormatter( + fmt=settings.LOG_FORMAT, + datefmt=settings.LOG_DATE_FORMAT, + ) + console_handler.setFormatter(console_formatter) + root_logger.addHandler(console_handler) + + # File handler with rotation + file_handler = RotatingFileHandler( + filename=log_file_path, + maxBytes=settings.LOG_MAX_BYTES, + backupCount=settings.LOG_BACKUP_COUNT, + encoding="utf-8", + ) + file_handler.setLevel(log_level) + file_formatter = logging.Formatter( + fmt=settings.LOG_FORMAT, + datefmt=settings.LOG_DATE_FORMAT, + ) + file_handler.setFormatter(file_formatter) + root_logger.addHandler(file_handler) + + # Reduce noise from third-party libraries + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) + + return root_logger + + +def get_logger(name: str) -> logging.Logger: + """Get a logger instance for the given name. + + Parameters + ---------- + name : str + Name for the logger (typically __name__). + + Returns + ------- + logging.Logger + Logger instance. + """ + return logging.getLogger(name) + + +# Default initialization for backwards compatibility +# This will be overridden when setup_logging is called with settings LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs") if not os.path.exists(LOG_DIR): os.makedirs(LOG_DIR) LOG_FILE_PATH = os.path.join(LOG_DIR, "app.log") - LOGGING_LEVEL = logging.INFO LOGGING_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" logging.basicConfig(level=LOGGING_LEVEL, format=LOGGING_FORMAT) -file_handler = RotatingFileHandler(LOG_FILE_PATH, maxBytes=10485760, backupCount=5) -file_handler.setLevel(LOGGING_LEVEL) -file_handler.setFormatter(logging.Formatter(LOGGING_FORMAT)) - -logging.getLogger("").addHandler(file_handler) +_file_handler = RotatingFileHandler(LOG_FILE_PATH, maxBytes=10485760, backupCount=5) +_file_handler.setLevel(LOGGING_LEVEL) +_file_handler.setFormatter(logging.Formatter(LOGGING_FORMAT)) +logging.getLogger("").addHandler(_file_handler) diff --git a/src/app/core/setup.py b/src/app/core/setup.py index b2cdcbf7..28e63662 100644 --- a/src/app/core/setup.py +++ b/src/app/core/setup.py @@ -14,6 +14,7 @@ from ..api.dependencies import get_current_superuser from ..core.utils.rate_limit import rate_limiter +from ..middleware import ExceptionHandlerMiddleware, LoggingMiddleware, setup_exception_handlers from ..middleware.client_cache_middleware import ClientCacheMiddleware from ..models import * # noqa: F403 from .config import ( @@ -23,6 +24,7 @@ DatabaseSettings, EnvironmentOption, EnvironmentSettings, + LoggingSettings, RedisCacheSettings, RedisQueueSettings, RedisRateLimiterSettings, @@ -30,6 +32,7 @@ ) from .db.database import Base from .db.database import async_engine as engine +from .logger import setup_logging from .utils import cache, queue @@ -86,6 +89,7 @@ def lifespan_factory( | RedisQueueSettings | RedisRateLimiterSettings | EnvironmentSettings + | LoggingSettings ), create_tables_on_start: bool = True, ) -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]]: @@ -142,6 +146,7 @@ def create_application( | RedisQueueSettings | RedisRateLimiterSettings | EnvironmentSettings + | LoggingSettings ), create_tables_on_start: bool = True, lifespan: Callable[[FastAPI], _AsyncGeneratorContextManager[Any]] | None = None, @@ -208,8 +213,30 @@ def create_application( application = FastAPI(lifespan=lifespan, **kwargs) application.include_router(router) + # Initialize logging if LoggingSettings is provided + if isinstance(settings, LoggingSettings): + setup_logging(settings) + + # Register exception handlers (catches validation errors during request parsing) + # This must be done BEFORE adding middlewares + setup_exception_handlers(application) + + # Exception handler middleware (catches exceptions during request processing) + if isinstance(settings, EnvironmentSettings): + is_debug = settings.ENVIRONMENT != EnvironmentOption.PRODUCTION + application.add_middleware(ExceptionHandlerMiddleware, debug=is_debug) # type: ignore[arg-type] + + # Logging middleware (logs all requests/responses) + if isinstance(settings, LoggingSettings): + application.add_middleware( + LoggingMiddleware, # type: ignore[arg-type] + log_request_body=settings.LOG_REQUEST_BODY, + log_response_body=settings.LOG_RESPONSE_BODY, + exclude_paths=settings.LOG_EXCLUDE_PATHS, + ) + if isinstance(settings, ClientSideCacheSettings): - application.add_middleware(ClientCacheMiddleware, max_age=settings.CLIENT_CACHE_MAX_AGE) + application.add_middleware(ClientCacheMiddleware, max_age=settings.CLIENT_CACHE_MAX_AGE) # type: ignore[arg-type] if isinstance(settings, CORSSettings): application.add_middleware( diff --git a/src/app/middleware/__init__.py b/src/app/middleware/__init__.py new file mode 100644 index 00000000..f27c40e0 --- /dev/null +++ b/src/app/middleware/__init__.py @@ -0,0 +1,19 @@ +""" +Middleware package for FastAPI application. + +This package contains custom middleware classes for: +- Exception handling with consistent JSON responses +- Request/Response logging +- Client-side cache headers +""" + +from .client_cache_middleware import ClientCacheMiddleware +from .exception_handler_middleware import ExceptionHandlerMiddleware, setup_exception_handlers +from .logging_middleware import LoggingMiddleware + +__all__ = [ + "ClientCacheMiddleware", + "ExceptionHandlerMiddleware", + "LoggingMiddleware", + "setup_exception_handlers", +] diff --git a/src/app/middleware/exception_handler_middleware.py b/src/app/middleware/exception_handler_middleware.py new file mode 100644 index 00000000..3643cf5d --- /dev/null +++ b/src/app/middleware/exception_handler_middleware.py @@ -0,0 +1,309 @@ +"""Exception Handler Middleware for FastAPI. + +Catches all exceptions and returns consistent JSON responses using the APIResponse schema. +Handles: +- RequestValidationError (422): Pydantic/FastAPI validation errors +- HTTPException: Standard FastAPI HTTP exceptions +- CustomException: FastCRUD custom exceptions +- Generic Exception (500): Unexpected server errors +""" + +import logging +import traceback + +from fastapi import FastAPI, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + +from ..core.config import EnvironmentOption, settings +from ..schemas.api_response import APIResponse, ValidationErrorDetail + +logger = logging.getLogger(__name__) + + +# User-friendly error message translations for Pydantic error types +ERROR_MESSAGES = { + # Missing/Required errors + "missing": "This field is required", + "value_error.missing": "This field is required", + # Type errors + "type_error.none.not_allowed": "This field cannot be null", + "type_error.integer": "Must be a valid integer", + "type_error.float": "Must be a valid number", + "type_error.bool": "Must be true or false", + "type_error.str": "Must be a valid string", + "type_error.list": "Must be a valid list", + "type_error.dict": "Must be a valid object", + # String validation + "string_too_short": "This field is too short", + "string_too_long": "This field is too long", + "string_type": "Must be a valid string", + "string_pattern_mismatch": "Invalid format", + # Email validation + "value_error.email": "Must be a valid email address", + # Number validation + "greater_than": "Value is too small", + "greater_than_equal": "Value is too small", + "less_than": "Value is too large", + "less_than_equal": "Value is too large", + # JSON errors + "json_invalid": "Invalid JSON format", + "json_type": "Expected valid JSON", + # Extra fields + "extra_forbidden": "Unknown field not allowed", +} + + +def get_friendly_message(error_type: str, original_msg: str, ctx: dict | None = None) -> str: + """Convert Pydantic error type to user-friendly message. + + Parameters + ---------- + error_type : str + The Pydantic error type (e.g., "missing", "string_too_short") + original_msg : str + The original Pydantic error message + ctx : dict | None + Additional context from the error (e.g., min_length, max_length) + + Returns + ------- + str + User-friendly error message + """ + # Check if we have a custom message for this error type + if error_type in ERROR_MESSAGES: + base_msg = ERROR_MESSAGES[error_type] + + # Add context if available + if ctx: + if error_type == "string_too_short" and "min_length" in ctx: + return f"Must be at least {ctx['min_length']} characters" + if error_type == "string_too_long" and "max_length" in ctx: + return f"Must be at most {ctx['max_length']} characters" + if error_type == "greater_than" and "gt" in ctx: + return f"Must be greater than {ctx['gt']}" + if error_type == "less_than" and "lt" in ctx: + return f"Must be less than {ctx['lt']}" + + return base_msg + + # Return original message if no translation found + return original_msg + + +class ExceptionHandlerMiddleware(BaseHTTPMiddleware): + """Middleware to catch and handle all exceptions with consistent JSON responses. + + This middleware wraps all request handling and catches any exceptions that occur, + converting them into standardized APIResponse JSON format. + + Parameters + ---------- + app : FastAPI + The FastAPI application instance. + debug : bool, optional + If True, includes stack traces in error responses. Defaults to False. + + Attributes + ---------- + debug : bool + Whether to include detailed error information in responses. + """ + + def __init__(self, app: FastAPI, debug: bool = False) -> None: + super().__init__(app) + self.debug = debug + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """Process the request and handle any exceptions. + + Parameters + ---------- + request : Request + The incoming HTTP request. + call_next : RequestResponseEndpoint + The next middleware or route handler. + + Returns + ------- + Response + Either the normal response or an error response in APIResponse format. + """ + try: + response = await call_next(request) + return response + + except RequestValidationError as exc: + return self._handle_validation_error(exc) + + except StarletteHTTPException as exc: + return self._handle_http_exception(exc) + + except Exception as exc: + return self._handle_generic_exception(exc, request) + + def _handle_validation_error(self, exc: RequestValidationError) -> JSONResponse: + """Handle Pydantic/FastAPI validation errors.""" + errors = [] + for error in exc.errors(): + errors.append( + ValidationErrorDetail( + loc=[str(loc) for loc in error.get("loc", [])], + msg=error.get("msg", "Validation error"), + type=error.get("type", "value_error"), + ) + ) + + logger.warning(f"Validation error: {errors}") + + response = APIResponse( + data=[error.model_dump() for error in errors], + isSuccess=False, + message="Validation failed", + statusCode=422, + ) + + return JSONResponse( + status_code=422, + content=response.model_dump(), + ) + + def _handle_http_exception(self, exc: StarletteHTTPException) -> JSONResponse: + """Handle FastAPI/Starlette HTTP exceptions.""" + logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}") + + response: APIResponse[None] = APIResponse( + data=None, + isSuccess=False, + message=str(exc.detail) if exc.detail else "An error occurred", + statusCode=exc.status_code, + ) + + return JSONResponse( + status_code=exc.status_code, + content=response.model_dump(), + ) + + def _handle_generic_exception(self, exc: Exception, request: Request) -> JSONResponse: + """Handle unexpected exceptions.""" + # Log the full traceback for debugging + logger.error( + f"Unhandled exception on {request.method} {request.url.path}: {exc}", + exc_info=True, + ) + + # Build error message based on debug mode + message = "Internal server error" + data = None + + if self.debug: + message = str(exc) + data = { + "exception_type": type(exc).__name__, + "traceback": traceback.format_exc(), + } + + response = APIResponse( + data=data, + isSuccess=False, + message=message, + statusCode=500, + ) + + return JSONResponse( + status_code=500, + content=response.model_dump(), + ) + + +def setup_exception_handlers(app: FastAPI) -> None: + """Register exception handlers directly on the FastAPI app. + + This is an alternative to the middleware approach, using FastAPI's + built-in exception handler registration. + + Parameters + ---------- + app : FastAPI + The FastAPI application instance. + """ + is_debug = settings.ENVIRONMENT != EnvironmentOption.PRODUCTION + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + errors = [] + for error in exc.errors(): + error_type = error.get("type", "value_error") + original_msg = error.get("msg", "Validation error") + ctx = error.get("ctx") # Contains min_length, max_length, etc. + + # Get user-friendly message + friendly_msg = get_friendly_message(error_type, original_msg, ctx) + + # Get field name (last item in location, skip 'body') + loc = error.get("loc", []) + field_name = loc[-1] if loc else "field" + + errors.append( + { + "field": str(field_name), + "message": friendly_msg, + "type": error_type, + } + ) + + logger.warning(f"Validation error on {request.url.path}: {errors}") + + return JSONResponse( + status_code=422, + content=APIResponse( + data=errors, + isSuccess=False, + message="Validation failed", + statusCode=422, + ).model_dump(), + ) + + @app.exception_handler(StarletteHTTPException) + async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: + logger.warning(f"HTTP {exc.status_code} on {request.url.path}: {exc.detail}") + + return JSONResponse( + status_code=exc.status_code, + content=APIResponse( + data=None, + isSuccess=False, + message=str(exc.detail) if exc.detail else "An error occurred", + statusCode=exc.status_code, + ).model_dump(), + ) + + @app.exception_handler(Exception) + async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: + logger.error( + f"Unhandled exception on {request.method} {request.url.path}: {exc}", + exc_info=True, + ) + + data = None + message = "Internal server error" + + if is_debug: + message = str(exc) + data = { + "exception_type": type(exc).__name__, + "traceback": traceback.format_exc(), + } + + return JSONResponse( + status_code=500, + content=APIResponse( + data=data, + isSuccess=False, + message=message, + statusCode=500, + ).model_dump(), + ) diff --git a/src/app/middleware/logging_middleware.py b/src/app/middleware/logging_middleware.py new file mode 100644 index 00000000..bbad0faa --- /dev/null +++ b/src/app/middleware/logging_middleware.py @@ -0,0 +1,181 @@ +""" +Request/Response Logging Middleware for FastAPI. + +Logs incoming requests and outgoing responses with timing information. +Configurable via LoggingSettings to include/exclude request/response bodies. +""" + +import logging +import time +import uuid + +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + +logger = logging.getLogger(__name__) + + +class LoggingMiddleware(BaseHTTPMiddleware): + """Middleware to log incoming requests and outgoing responses. + + Features: + - Logs request method, path, query params, and client IP + - Logs response status code, content length, and processing time + - Optionally logs request/response bodies (configurable) + - Assigns a unique request ID for correlation + - Excludes configurable paths from logging (e.g., health checks) + + Parameters + ---------- + app : FastAPI + The FastAPI application instance. + log_request_body : bool, optional + Whether to log request bodies. Default: False + log_response_body : bool, optional + Whether to log response bodies. Default: False + exclude_paths : list[str], optional + Paths to exclude from logging. Default: ["/health", "/metrics"] + """ + + def __init__( + self, + app: FastAPI, + log_request_body: bool = False, + log_response_body: bool = False, + exclude_paths: list[str] | None = None, + ) -> None: + super().__init__(app) + self.log_request_body = log_request_body + self.log_response_body = log_response_body + self.exclude_paths = exclude_paths or ["/health", "/metrics", "/favicon.ico"] + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """Process and log the request/response cycle. + + Parameters + ---------- + request : Request + The incoming HTTP request. + call_next : RequestResponseEndpoint + The next middleware or route handler. + + Returns + ------- + Response + The response from the route handler. + """ + # Skip logging for excluded paths + if any(request.url.path.startswith(path) for path in self.exclude_paths): + return await call_next(request) + + # Generate unique request ID + request_id = str(uuid.uuid4())[:8] + + # Start timing + start_time = time.perf_counter() + + # Log incoming request + await self._log_request(request, request_id) + + # Process request + try: + response = await call_next(request) + except Exception as exc: + # Log exception and re-raise (will be caught by exception middleware) + process_time = time.perf_counter() - start_time + logger.error(f"[{request_id}] Request failed after {process_time:.3f}s: {type(exc).__name__}: {exc}") + raise + + # Calculate processing time + process_time = time.perf_counter() - start_time + + # Log outgoing response + self._log_response(request, response, request_id, process_time) + + # Add request ID and timing headers to response + response.headers["X-Request-ID"] = request_id + response.headers["X-Process-Time"] = f"{process_time:.3f}s" + + return response + + async def _log_request(self, request: Request, request_id: str) -> None: + """Log details of the incoming request. + + Parameters + ---------- + request : Request + The incoming HTTP request. + request_id : str + Unique identifier for this request. + """ + # Get client IP (handle proxied requests) + client_ip = request.client.host if request.client else "unknown" + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + client_ip = forwarded_for.split(",")[0].strip() + + # Build log message + query_string = f"?{request.url.query}" if request.url.query else "" + user_agent = request.headers.get("User-Agent", "unknown")[:50] + + logger.info( + f"[{request_id}] --> {request.method} {request.url.path}{query_string} from {client_ip} ({user_agent})" + ) + + # Optionally log request body + if self.log_request_body and request.method in ("POST", "PUT", "PATCH"): + try: + body = await request.body() + if body: + # Limit body size in logs + body_str = body.decode("utf-8")[:1000] + logger.debug(f"[{request_id}] Request body: {body_str}") + except Exception as e: + logger.debug(f"[{request_id}] Could not read request body: {e}") + + def _log_response( + self, + request: Request, + response: Response, + request_id: str, + process_time: float, + ) -> None: + """Log details of the outgoing response. + + Parameters + ---------- + request : Request + The original request. + response : Response + The outgoing response. + request_id : str + Unique identifier for this request. + process_time : float + Time taken to process the request in seconds. + """ + content_length = response.headers.get("Content-Length", "unknown") + + # Determine log level based on status code + status_code = response.status_code + if status_code >= 500: + log_func = logger.error + elif status_code >= 400: + log_func = logger.warning + else: + log_func = logger.info + + log_func( + f"[{request_id}] <-- {request.method} {request.url.path} " + f"{status_code} ({content_length} bytes) in {process_time:.3f}s" + ) + + +def create_logging_middleware() -> type[LoggingMiddleware]: + """Factory function to create LoggingMiddleware with settings from config. + + Returns + ------- + type[LoggingMiddleware] + Configured LoggingMiddleware class ready to be added to the app. + """ + return LoggingMiddleware diff --git a/src/app/schemas/api_response.py b/src/app/schemas/api_response.py new file mode 100644 index 00000000..b799a3de --- /dev/null +++ b/src/app/schemas/api_response.py @@ -0,0 +1,98 @@ +""" +Standard API response schema for consistent JSON responses. + +All API responses follow this structure: +- data: The actual response payload (can be any type or None) +- isSuccess: Boolean indicating if the request was successful +- message: Human-readable message describing the result +- statusCode: HTTP status code matching the response status +""" + +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class APIResponse(BaseModel, Generic[T]): + """Generic API response wrapper for consistent JSON structure. + + Attributes + ---------- + data : T | None + The response payload. Can be any type (object, list, etc.) or None for error responses. + isSuccess : bool + Indicates whether the request was processed successfully. + message : str + Human-readable message describing the result or error. + statusCode : int + HTTP status code matching the response status. + + Examples + -------- + Success response: + >>> APIResponse(data={"id": 1}, isSuccess=True, message="User created", statusCode=201) + + Error response: + >>> APIResponse(data=None, isSuccess=False, message="Not found", statusCode=404) + """ + + data: T | None = Field(default=None, description="Response payload") + isSuccess: bool = Field(..., description="Whether the request succeeded") + message: str = Field(..., description="Human-readable result message") + statusCode: int = Field(..., description="HTTP status code", ge=100, le=599) + + model_config = { + "json_schema_extra": { + "example": { + "data": {"id": 1, "name": "example"}, + "isSuccess": True, + "message": "Request successful", + "statusCode": 200, + } + } + } + + +class ValidationErrorDetail(BaseModel): + """Detail for a single validation error.""" + + loc: list[str | int] = Field(..., description="Location of the error (field path)") + msg: str = Field(..., description="Error message") + type: str = Field(..., description="Error type identifier") + + +class ValidationErrorResponse(APIResponse[list[ValidationErrorDetail]]): + """Specialized response for validation errors (422).""" + + pass + + +# Helper functions for creating responses +def success_response( + data: Any = None, + message: str = "Request successful", + status_code: int = 200, +) -> APIResponse: + """Create a success response.""" + return APIResponse( + data=data, + isSuccess=True, + message=message, + statusCode=status_code, + ) + + +def error_response( + message: str, + status_code: int = 400, + data: Any = None, +) -> APIResponse: + """Create an error response.""" + return APIResponse( + data=data, + isSuccess=False, + message=message, + statusCode=status_code, + )