mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-30 01:27:00 -05:00
feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.
This commit is contained in:
@@ -7,7 +7,8 @@ TypeScript interfaces, providing immediate feedback during development.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
@@ -19,52 +20,49 @@ 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'))
|
||||
|
||||
data = response.data if isinstance(response, Response) else 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(
|
||||
@@ -76,55 +74,55 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
'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,
|
||||
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,
|
||||
@@ -132,7 +130,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
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
|
||||
@@ -163,10 +161,10 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
"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,
|
||||
@@ -174,7 +172,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
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:
|
||||
@@ -184,7 +182,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
"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(
|
||||
@@ -192,13 +190,13 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
"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']
|
||||
@@ -208,14 +206,14 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
"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:
|
||||
|
||||
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:
|
||||
@@ -225,7 +223,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
"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(
|
||||
@@ -233,17 +231,17 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
"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:
|
||||
@@ -255,16 +253,16 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def _log_contract_violation(
|
||||
self,
|
||||
path: str,
|
||||
violation_type: str,
|
||||
message: str,
|
||||
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,
|
||||
@@ -273,15 +271,15 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
'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. "
|
||||
@@ -308,31 +306,31 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
"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."""
|
||||
|
||||
Reference in New Issue
Block a user