From 56d510e75fef3792ea7bdfe8294090880a248854 Mon Sep 17 00:00:00 2001 From: aitbc Date: Thu, 30 Apr 2026 11:08:39 +0200 Subject: [PATCH] Add request validation and error handling middleware - Created RequestValidationMiddleware for request/response size validation - Created ErrorHandlerMiddleware for standardized error responses - Added both middlewares to FastAPI app - Created validation patterns documentation - Configured 10MB default size limits for requests and responses This completes Phase 6: Request Validation Middleware --- apps/coordinator-api/src/app/main.py | 8 + .../src/app/middleware/error_handler.py | 61 +++++ .../src/app/middleware/validation.py | 66 ++++++ docs/development/validation-patterns.md | 218 ++++++++++++++++++ 4 files changed, 353 insertions(+) create mode 100644 apps/coordinator-api/src/app/middleware/error_handler.py create mode 100644 apps/coordinator-api/src/app/middleware/validation.py create mode 100644 docs/development/validation-patterns.md diff --git a/apps/coordinator-api/src/app/main.py b/apps/coordinator-api/src/app/main.py index e478c8eb..b801f4fd 100755 --- a/apps/coordinator-api/src/app/main.py +++ b/apps/coordinator-api/src/app/main.py @@ -94,6 +94,8 @@ from aitbc import get_logger from .app_logging import configure_logging from .middleware.request_id import RequestIDMiddleware from .middleware.performance import PerformanceLoggingMiddleware +from .middleware.validation import RequestValidationMiddleware +from .middleware.error_handler import ErrorHandlerMiddleware from .exceptions import AITBCError, ErrorResponse # Configure structured logging @@ -292,6 +294,12 @@ def create_app() -> FastAPI: # Add performance logging middleware app.add_middleware(PerformanceLoggingMiddleware) + + # Add request validation middleware + app.add_middleware(RequestValidationMiddleware, max_request_size=10*1024*1024) + + # Add error handler middleware + app.add_middleware(ErrorHandlerMiddleware) @app.middleware("http") async def request_metrics_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: diff --git a/apps/coordinator-api/src/app/middleware/error_handler.py b/apps/coordinator-api/src/app/middleware/error_handler.py new file mode 100644 index 00000000..1a6a19bd --- /dev/null +++ b/apps/coordinator-api/src/app/middleware/error_handler.py @@ -0,0 +1,61 @@ +""" +Standardized error response middleware for FastAPI +""" + +from typing import Callable + +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from ..app_logging import get_logger + +logger = get_logger(__name__) + + +class ErrorHandlerMiddleware(BaseHTTPMiddleware): + """Middleware to standardize error responses""" + + async def dispatch(self, request: Request, call_next: Callable) -> JSONResponse: + try: + response = await call_next(request) + return response + except HTTPException as e: + logger.warning( + "HTTP exception", + status_code=e.status_code, + detail=e.detail, + path=request.url.path, + method=request.method, + ) + return JSONResponse( + status_code=e.status_code, + content={ + "error": { + "type": "http_error", + "message": e.detail, + "status_code": e.status_code, + "path": request.url.path, + } + }, + ) + except Exception as e: + logger.error( + "Unhandled exception", + error=str(e), + path=request.url.path, + method=request.method, + exc_info=True, + ) + return JSONResponse( + status_code=500, + content={ + "error": { + "type": "internal_error", + "message": "An internal server error occurred", + "status_code": 500, + "path": request.url.path, + } + }, + ) diff --git a/apps/coordinator-api/src/app/middleware/validation.py b/apps/coordinator-api/src/app/middleware/validation.py new file mode 100644 index 00000000..db3e6f8c --- /dev/null +++ b/apps/coordinator-api/src/app/middleware/validation.py @@ -0,0 +1,66 @@ +""" +Request validation middleware for FastAPI +""" + +from typing import Callable + +from fastapi import Request, HTTPException +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from ..app_logging import get_logger + +logger = get_logger(__name__) + + +class RequestValidationMiddleware(BaseHTTPMiddleware): + """Middleware to validate incoming requests""" + + def __init__( + self, + app: ASGIApp, + max_request_size: int = 10 * 1024 * 1024, # 10MB default + max_response_size: int = 10 * 1024 * 1024, # 10MB default + ) -> None: + super().__init__(app) + self.max_request_size = max_request_size + self.max_response_size = max_response_size + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Validate request size + content_length = request.headers.get("content-length") + if content_length: + try: + size = int(content_length) + if size > self.max_request_size: + logger.warning( + "Request too large", + content_length=size, + max_size=self.max_request_size, + client=request.client.host if request.client else "unknown", + ) + raise HTTPException( + status_code=413, + detail=f"Request too large. Maximum size is {self.max_request_size} bytes", + ) + except ValueError: + logger.warning("Invalid content-length header", content_length=content_length) + + # Process request + response = await call_next(request) + + # Validate response size + response_size = len(response.body) + if response_size > self.max_response_size: + logger.warning( + "Response too large", + response_size=response_size, + max_size=self.max_response_size, + path=request.url.path, + ) + raise HTTPException( + status_code=500, + detail="Response too large", + ) + + return response diff --git a/docs/development/validation-patterns.md b/docs/development/validation-patterns.md new file mode 100644 index 00000000..f9faf716 --- /dev/null +++ b/docs/development/validation-patterns.md @@ -0,0 +1,218 @@ +# Request Validation Patterns + +## Overview + +This document describes the request validation patterns and middleware for AITBC services. + +## Middleware Stack + +The coordinator-api uses the following middleware stack (in order of execution): + +1. **CORSMiddleware** - CORS handling +2. **RequestIDMiddleware** - Request ID correlation +3. **PerformanceLoggingMiddleware** - Performance tracking +4. **RequestValidationMiddleware** - Request/response size validation +5. **ErrorHandlerMiddleware** - Standardized error responses + +## Request Validation Middleware + +### Purpose + +Validates incoming and outgoing requests/responses to ensure they meet size limits and other constraints. + +### Configuration + +```python +app.add_middleware( + RequestValidationMiddleware, + max_request_size=10*1024*1024, # 10MB +) +``` + +### Validation Rules + +- **Request size**: Maximum 10MB by default +- **Response size**: Maximum 10MB by default +- **Content-Length header**: Must be valid integer if present + +### Error Responses + +If validation fails, returns HTTP 413 (Payload Too Large): + +```json +{ + "detail": "Request too large. Maximum size is 10485760 bytes" +} +``` + +## Error Handler Middleware + +### Purpose + +Standardizes error responses across all endpoints with consistent format and logging. + +### Error Response Format + +All errors are returned in the following format: + +```json +{ + "error": { + "type": "http_error | internal_error", + "message": "Error description", + "status_code": 400 | 500, + "path": "/api/endpoint" + } +} +``` + +### Error Types + +- **http_error**: HTTP exceptions (4xx errors) +- **internal_error**: Unhandled exceptions (5xx errors) + +### Logging + +All errors are logged with context: +- HTTP exceptions: WARNING level +- Internal exceptions: ERROR level with stack trace + +## Request ID Correlation + +### Purpose + +Adds a unique request ID to each request for correlation across distributed systems. + +### Implementation + +- Generates or retrieves request ID from `X-Request-ID` header +- Binds request ID to logger context +- Adds request ID to response headers +- Logs request start and completion + +### Usage + +```python +request_id = request.state.request_id +logger = logger.bind(request_id=request_id) +``` + +## Performance Logging + +### Purpose + +Tracks request timing and performance metrics. + +### Implementation + +- Logs request duration in milliseconds +- Adds `X-Process-Time` header to responses +- Logs method, path, and status code + +### Response Header + +``` +X-Process-Time: 0.123 +``` + +## Validation Guidelines + +### DO + +- Use Pydantic models for request body validation +- Set appropriate size limits for your use case +- Log validation failures with context +- Return standardized error responses +- Include request ID in error logs + +### DON'T + +- Disable validation in production +- Allow unbounded request sizes +- Return raw exceptions to clients +- Log sensitive information in error messages +- Skip error logging + +## Example: Adding Custom Validation + +```python +from fastapi import Request, HTTPException +from pydantic import BaseModel, Field, validator + +class CreateUserRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + email: str = Field(..., regex=r'^[^@]+@[^@]+\.[^@]+$') + + @validator('username') + def validate_username(cls, v): + if not v.isalnum(): + raise ValueError('Username must be alphanumeric') + return v + +@router.post("/users") +async def create_user(request: CreateUserRequest): + # Request is automatically validated by FastAPI + return {"status": "created", "username": request.username} +``` + +## Rate Limiting + +Rate limiting is already implemented using slowapi: + +```python +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) + +@router.post("/endpoint") +@limiter.limit("10/minute") +async def endpoint(request: Request): + return {"status": "ok"} +``` + +## Configuration + +Middleware can be configured in `main.py`: + +```python +# Request validation +app.add_middleware( + RequestValidationMiddleware, + max_request_size=10*1024*1024, # 10MB + max_response_size=10*1024*1024, # 10MB +) + +# Error handling +app.add_middleware(ErrorHandlerMiddleware) +``` + +## Testing + +Test validation middleware: + +```python +from fastapi.testclient import TestClient + +client = TestClient(app) + +# Test request size validation +response = client.post( + "/endpoint", + data="x" * 11 * 1024 * 1024, # 11MB +) +assert response.status_code == 413 + +# Test error handling +response = client.get("/nonexistent") +assert response.status_code == 404 +assert "error" in response.json() +``` + +## Security Considerations + +- Request size limits prevent DoS attacks +- Response size limits prevent memory exhaustion +- Error responses don't leak sensitive information +- Request IDs enable security audit trails +- All validation failures are logged for monitoring