"""Envelope-aware DRF exception handler and error helpers (``API-STANDARD.md`` §4, §5).""" from __future__ import annotations from typing import Any from django.http import JsonResponse from rest_framework import status as http_status from rest_framework.exceptions import ( APIException, AuthenticationFailed, NotAuthenticated, NotFound, PermissionDenied, Throttled, ValidationError, ) from rest_framework.response import Response from rest_framework.views import exception_handler as drf_exception_handler from infrasynth.shared.exceptions import AppError __all__ = ["envelope_exception_handler", "error_response", "envelope_body"] def _flatten_details(detail: Any) -> list[dict[str, Any]]: """Turns a DRF ``detail`` (dict/list/str) into the standard details array.""" if isinstance(detail, dict): flattened: list[dict[str, Any]] = [] for field, issue in detail.items(): if isinstance(issue, (list, tuple)): for entry in issue: flattened.append({"field": str(field), "issue": str(entry)}) else: flattened.append({"field": str(field), "issue": str(issue)}) return flattened if isinstance(detail, (list, tuple)): return [{"issue": str(entry)} for entry in detail] if detail is None: return [] return [{"issue": str(detail)}] def _map_exception(exc: APIException) -> tuple[str, str]: if isinstance(exc, ValidationError): return "VALIDATION_ERROR", "The request payload is invalid." if isinstance(exc, (NotAuthenticated, AuthenticationFailed)): return "AUTH_UNAUTHENTICATED", "Authentication credentials were not provided or are invalid." if isinstance(exc, PermissionDenied): return "AUTH_FORBIDDEN", "You do not have permission to perform this action." if isinstance(exc, NotFound): return "NOT_FOUND", "The requested resource was not found." if isinstance(exc, Throttled): return "RATE_LIMIT_EXCEEDED", "Too many requests." return "SERVER_ERROR", "An unexpected error occurred." def envelope_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None: """DRF ``EXCEPTION_HANDLER`` producing ``{code, message, details}`` bodies. The envelope itself is added by :class:`EnvelopeJSONRenderer` based on the HTTP status. Unexpected (non-``APIException``, non-``AppError``) exceptions return ``None`` so Django still surfaces them loudly instead of masking a bug behind a generic 500. """ if isinstance(exc, AppError): return Response(exc.to_dict(), status=exc.status) response = drf_exception_handler(exc, context) if response is None: return None code, message = _map_exception(exc) if isinstance(exc, APIException) else ("SERVER_ERROR", str(exc)) detail = getattr(exc, "detail", None) response.data = { "code": code, "message": message, "details": _flatten_details(detail), } return response def envelope_body( *, success: bool, data: Any = None, error: dict[str, Any] | None = None, meta: dict[str, Any] | None = None, ) -> dict[str, Any]: """Builds an envelope dict for middleware that bypasses DRF views.""" return {"success": success, "data": data, "error": error, "meta": meta or {}} def error_response( code: str, message: str, *, status_code: int = http_status.HTTP_403_FORBIDDEN, details: list[dict[str, Any]] | None = None, request: Any = None, ) -> JsonResponse: """Minimal envelope error for middleware (no DRF renderer in the chain).""" from django.utils import timezone body = envelope_body( success=False, error={"code": code, "message": message, "details": details or []}, meta={ "requestId": getattr(request, "request_id", "") if request else "", "timestamp": timezone.now().isoformat().replace("+00:00", "Z"), }, ) response = JsonResponse(body, status=status_code) request_id = getattr(request, "request_id", "") if request else "" if request_id: response["X-Request-Id"] = request_id return response