""" Contract Validation Middleware for ThrillWiki API This middleware catches contract violations between the Django backend and frontend TypeScript interfaces, providing immediate feedback during development. """ import json import logging from typing import Dict, Any, Optional from django.conf import settings from django.http import JsonResponse from django.utils.deprecation import MiddlewareMixin from rest_framework.response import Response logger = logging.getLogger(__name__) class ContractValidationMiddleware(MiddlewareMixin): """ Development-only middleware that validates API responses against expected contracts. This middleware: 1. Checks all API responses for contract compliance 2. Logs warnings when responses don't match expected TypeScript interfaces 3. Specifically validates filter metadata structure 4. Alerts when categorical filters are strings instead of objects Only active when DEBUG=True to avoid performance impact in production. """ def __init__(self, get_response): super().__init__(get_response) self.get_response = get_response self.enabled = getattr(settings, 'DEBUG', False) if self.enabled: logger.info("Contract validation middleware enabled (DEBUG mode)") def process_response(self, request, response): """Process API responses to check for contract violations.""" if not self.enabled: return response # Only validate API endpoints if not request.path.startswith('/api/'): return response # Only validate JSON responses if not isinstance(response, (JsonResponse, Response)): return response # Only validate successful responses (2xx status codes) if not (200 <= response.status_code < 300): return response try: # Get response data if isinstance(response, Response): data = response.data else: data = json.loads(response.content.decode('utf-8')) # Validate the response self._validate_response_contract(request.path, data) except Exception as e: # Log validation errors but don't break the response logger.warning( f"Contract validation error for {request.path}: {str(e)}", extra={ 'path': request.path, 'method': request.method, 'status_code': response.status_code, 'validation_error': str(e) } ) return response def _validate_response_contract(self, path: str, data: Any) -> None: """Validate response data against expected contracts.""" # Check for filter metadata endpoints if 'filter-options' in path or 'filter_options' in path: self._validate_filter_metadata(path, data) # Check for hybrid filtering endpoints if 'hybrid' in path: self._validate_hybrid_response(path, data) # Check for pagination responses if isinstance(data, dict) and 'results' in data: self._validate_pagination_response(path, data) # Check for common contract violations self._validate_common_patterns(path, data) def _validate_filter_metadata(self, path: str, data: Any) -> None: """Validate filter metadata structure.""" if not isinstance(data, dict): self._log_contract_violation( path, "FILTER_METADATA_NOT_DICT", f"Filter metadata should be a dictionary, got {type(data).__name__}" ) return # Check for categorical filters if 'categorical' in data: categorical = data['categorical'] if isinstance(categorical, dict): for filter_name, filter_options in categorical.items(): self._validate_categorical_filter(path, filter_name, filter_options) # Check for ranges if 'ranges' in data: ranges = data['ranges'] if isinstance(ranges, dict): for range_name, range_data in ranges.items(): self._validate_range_filter(path, range_name, range_data) def _validate_categorical_filter(self, path: str, filter_name: str, filter_options: Any) -> None: """Validate categorical filter options format.""" if not isinstance(filter_options, list): self._log_contract_violation( path, "CATEGORICAL_FILTER_NOT_ARRAY", f"Categorical filter '{filter_name}' should be an array, got {type(filter_options).__name__}" ) return for i, option in enumerate(filter_options): if isinstance(option, str): # CRITICAL: This is the main contract violation we're trying to catch self._log_contract_violation( path, "CATEGORICAL_OPTION_IS_STRING", f"Categorical filter '{filter_name}' option {i} is a string '{option}' but should be an object with value/label/count properties", severity="ERROR" ) elif isinstance(option, dict): # Validate object structure if 'value' not in option: self._log_contract_violation( path, "MISSING_VALUE_PROPERTY", f"Categorical filter '{filter_name}' option {i} missing 'value' property" ) if 'label' not in option: self._log_contract_violation( path, "MISSING_LABEL_PROPERTY", f"Categorical filter '{filter_name}' option {i} missing 'label' property" ) # Count is optional but should be number if present if 'count' in option and option['count'] is not None and not isinstance(option['count'], (int, float)): self._log_contract_violation( path, "INVALID_COUNT_TYPE", f"Categorical filter '{filter_name}' option {i} 'count' should be a number, got {type(option['count']).__name__}" ) def _validate_range_filter(self, path: str, range_name: str, range_data: Any) -> None: """Validate range filter format.""" if not isinstance(range_data, dict): self._log_contract_violation( path, "RANGE_FILTER_NOT_OBJECT", f"Range filter '{range_name}' should be an object, got {type(range_data).__name__}" ) return # Check required properties required_props = ['min', 'max'] for prop in required_props: if prop not in range_data: self._log_contract_violation( path, "MISSING_RANGE_PROPERTY", f"Range filter '{range_name}' missing required property '{prop}'" ) # Check step property if 'step' in range_data and not isinstance(range_data['step'], (int, float)): self._log_contract_violation( path, "INVALID_STEP_TYPE", f"Range filter '{range_name}' 'step' should be a number, got {type(range_data['step']).__name__}" ) def _validate_hybrid_response(self, path: str, data: Any) -> None: """Validate hybrid filtering response structure.""" if not isinstance(data, dict): return # Check for strategy field if 'strategy' in data: strategy = data['strategy'] if strategy not in ['client_side', 'server_side']: self._log_contract_violation( path, "INVALID_STRATEGY_VALUE", f"Hybrid response strategy should be 'client_side' or 'server_side', got '{strategy}'" ) # Check filter_metadata structure if 'filter_metadata' in data: self._validate_filter_metadata(path, data['filter_metadata']) def _validate_pagination_response(self, path: str, data: Dict[str, Any]) -> None: """Validate pagination response structure.""" # Check for required pagination fields required_fields = ['count', 'results'] for field in required_fields: if field not in data: self._log_contract_violation( path, "MISSING_PAGINATION_FIELD", f"Pagination response missing required field '{field}'" ) # Check results is array if 'results' in data and not isinstance(data['results'], list): self._log_contract_violation( path, "RESULTS_NOT_ARRAY", f"Pagination 'results' should be an array, got {type(data['results']).__name__}" ) def _validate_common_patterns(self, path: str, data: Any) -> None: """Validate common API response patterns.""" if isinstance(data, dict): # Check for null vs undefined issues for key, value in data.items(): if value is None and key.endswith('_id'): # ID fields should probably be null, not undefined continue # Check for numeric fields that might be strings if key.endswith('_count') and isinstance(value, str): try: int(value) self._log_contract_violation( path, "NUMERIC_FIELD_AS_STRING", f"Field '{key}' appears to be numeric but is a string: '{value}'" ) except ValueError: pass def _log_contract_violation( self, path: str, violation_type: str, message: str, severity: str = "WARNING" ) -> None: """Log a contract violation with structured data.""" log_data = { 'contract_violation': True, 'violation_type': violation_type, 'api_path': path, 'severity': severity, 'message': message, 'suggestion': self._get_violation_suggestion(violation_type) } if severity == "ERROR": logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) else: logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) def _get_violation_suggestion(self, violation_type: str) -> str: """Get suggestion for fixing a contract violation.""" suggestions = { "CATEGORICAL_OPTION_IS_STRING": ( "Convert string arrays to object arrays with {value, label, count} structure. " "Use the ensure_filter_option_format() utility function from apps.api.v1.serializers.shared" ), "MISSING_VALUE_PROPERTY": ( "Add 'value' property to filter option objects. " "Use FilterOptionSerializer from apps.api.v1.serializers.shared" ), "MISSING_LABEL_PROPERTY": ( "Add 'label' property to filter option objects. " "Use FilterOptionSerializer from apps.api.v1.serializers.shared" ), "RANGE_FILTER_NOT_OBJECT": ( "Convert range data to object with min/max/step/unit properties. " "Use FilterRangeSerializer from apps.api.v1.serializers.shared" ), "NUMERIC_FIELD_AS_STRING": ( "Ensure numeric fields are returned as numbers, not strings. " "Check serializer field types and database field types." ), "RESULTS_NOT_ARRAY": ( "Ensure pagination 'results' field is always an array. " "Check serializer implementation." ) } return suggestions.get(violation_type, "Check the API response format against frontend TypeScript interfaces.") class ContractValidationSettings: """Settings for contract validation middleware.""" # Enable/disable specific validation checks VALIDATE_FILTER_METADATA = True VALIDATE_PAGINATION = True VALIDATE_HYBRID_RESPONSES = True VALIDATE_COMMON_PATTERNS = True # Severity levels for different violations CATEGORICAL_STRING_SEVERITY = "ERROR" # This is the critical issue MISSING_PROPERTY_SEVERITY = "WARNING" TYPE_MISMATCH_SEVERITY = "WARNING" # Paths to exclude from validation EXCLUDED_PATHS = [ '/api/docs/', '/api/schema/', '/api/v1/auth/', # Auth endpoints might have different structures ] @classmethod def should_validate_path(cls, path: str) -> bool: """Check if a path should be validated.""" return not any(excluded in path for excluded in cls.EXCLUDED_PATHS)