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
This commit is contained in:
@@ -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
|
||||
@@ -293,6 +295,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:
|
||||
start_time = __import__("time").perf_counter()
|
||||
|
||||
61
apps/coordinator-api/src/app/middleware/error_handler.py
Normal file
61
apps/coordinator-api/src/app/middleware/error_handler.py
Normal file
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
66
apps/coordinator-api/src/app/middleware/validation.py
Normal file
66
apps/coordinator-api/src/app/middleware/validation.py
Normal file
@@ -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
|
||||
218
docs/development/validation-patterns.md
Normal file
218
docs/development/validation-patterns.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user