Coverage for src/lexigram/web/filters/builtin.py: 24%
71 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Built-in exception filters for Lexigram Web."""
3from __future__ import annotations
5import dataclasses
6from typing import Any
8from lexigram.web.errors.html_error_renderer import DebugHtmlErrorRenderer
9from lexigram.web.exceptions import (
10 DependencyResolutionError,
11 HTTPError,
12)
13from lexigram.web.transport.responses import JSONResponse
16class ValidationErrorFilter:
17 """Filter for handling Pydantic and Lexigram validation errors."""
19 def can_handle(self, exc: Exception) -> bool:
20 """Check if exception is a validation error."""
21 name = exc.__class__.__name__
22 return (
23 name in ("ValidationError", "PydanticValidationError")
24 or getattr(exc, "code", None) in ("validation_error", "VALIDATION_ERROR")
25 or (
26 isinstance(exc, ValueError)
27 and any(kw in str(exc) for kw in ("validation", "Invalid", "coercion"))
28 )
29 )
31 async def handle(self, exc: Any, _request: Any) -> JSONResponse:
32 """Transform ValidationError into a 422 JSONResponse with RFC 7807 format."""
33 raw_errors: list[Any] = []
34 if hasattr(exc, "errors") and callable(exc.errors):
35 raw_errors = exc.errors()
36 elif hasattr(exc, "errors") and isinstance(exc.errors, list):
37 raw_errors = exc.errors
38 elif hasattr(exc, "args") and exc.args:
39 raw_errors = [{"msg": str(arg)} for arg in exc.args]
41 errors = []
42 for e in raw_errors:
43 if dataclasses.is_dataclass(e) and not isinstance(e, type):
44 errors.append(dataclasses.asdict(e))
45 elif isinstance(e, dict):
46 errors.append(e)
47 elif hasattr(e, "field") and hasattr(e, "message"):
48 errors.append(
49 {
50 "field": e.field,
51 "message": e.message,
52 "code": getattr(e, "code", None),
53 }
54 )
55 else:
56 errors.append({"msg": str(e)})
58 from lexigram.web.errors.problem_detail import ProblemDetail
60 pd = ProblemDetail(
61 type="urn:lexigram:validation-error",
62 title="Validation Error",
63 status=422,
64 detail="Request validation failed",
65 errors=errors,
66 )
67 return JSONResponse(
68 content=pd.to_dict(),
69 status_code=422,
70 media_type="application/problem+json",
71 )
74class DependencyResolutionFilter:
75 """Filter for handling DependencyResolutionError."""
77 def can_handle(self, exc: Exception) -> bool:
78 """Check if exception is a DependencyResolutionError."""
79 return (
80 isinstance(exc, DependencyResolutionError)
81 or exc.__class__.__name__ == "DependencyResolutionError"
82 or getattr(exc, "code", None) == "dependency_resolution_error"
83 )
85 async def handle(self, exc: Any, _request: Any) -> JSONResponse:
86 """Transform DependencyResolutionError into a 500 RFC 7807 JSONResponse."""
87 from lexigram.web.errors.problem_detail import ProblemDetail
89 pd = ProblemDetail.internal_error(detail=getattr(exc, "detail", str(exc)))
90 return JSONResponse(
91 content=pd.to_dict(),
92 status_code=500,
93 media_type="application/problem+json",
94 )
97class DefaultExceptionFilter:
98 """Default filter that handles HTTPError and generic exceptions.
100 When ``debug=True`` and the HTTP client sends ``Accept: text/html``,
101 the response is a rich HTML error page showing the traceback and request
102 details. In all other cases a structured JSON response is returned.
103 """
105 def __init__(self, debug: bool = False) -> None:
106 """Initialise the filter.
108 Args:
109 debug: When ``True``, HTML error pages are rendered for browser
110 clients. Set this based on ``ServerConfig.debug`` or
111 the ``LEX_DEBUG`` environment variable.
112 """
113 self._debug = debug
114 self._html_renderer = DebugHtmlErrorRenderer()
116 def can_handle(self, exc: Exception) -> bool:
117 """Handle HTTPErrors and LexigramErrors specifically."""
118 from lexigram.contracts.exceptions import LexigramError
120 return (
121 isinstance(exc, (HTTPError, LexigramError))
122 or getattr(exc, "status_code", None) is not None
123 or hasattr(exc, "code")
124 )
126 async def handle(self, exc: Exception, request: Any) -> Any:
127 """Transform the exception into a JSON or HTML response.
129 When ``debug=True`` and the request prefers HTML, a rich debug page
130 is returned instead of JSON. All JSON responses use RFC 7807
131 ``application/problem+json`` format.
132 """
133 from lexigram.web.errors.problem_detail import ProblemDetail
135 if isinstance(exc, HTTPError):
136 if self._debug and self._html_renderer.should_render(request):
137 return self._html_renderer.render(
138 exc,
139 request,
140 status_code=exc.status_code,
141 title=f"{exc.status_code} {exc.detail}",
142 )
143 code = getattr(exc, "code", None)
144 pd = ProblemDetail(
145 type=(
146 f"urn:lexigram:{code.lower().replace('_', '-')}"
147 if code
148 else "about:blank"
149 ),
150 title=exc.detail,
151 status=exc.status_code,
152 detail=exc.detail,
153 )
154 return JSONResponse(
155 content=pd.to_dict(),
156 status_code=exc.status_code,
157 headers=exc.headers,
158 media_type="application/problem+json",
159 )
161 # Handle domain errors via MRO-ordered type mapping.
162 from lexigram.contracts.exceptions.domain import (
163 AuthenticationError,
164 AuthorizationError,
165 ConflictError,
166 DomainError,
167 NotFoundError,
168 PermissionDeniedError,
169 )
171 status_code = 500
172 type_uri = "urn:lexigram:internal-error"
173 title = "Internal Server Error"
175 if isinstance(exc, NotFoundError):
176 status_code, type_uri, title = 404, "urn:lexigram:not-found", "Not Found"
177 elif isinstance(exc, (PermissionDeniedError, AuthorizationError)):
178 status_code, type_uri, title = 403, "urn:lexigram:forbidden", "Forbidden"
179 elif isinstance(exc, AuthenticationError):
180 status_code, type_uri, title = (
181 401,
182 "urn:lexigram:unauthorized",
183 "Unauthorized",
184 )
185 elif isinstance(exc, ConflictError):
186 status_code, type_uri, title = 409, "urn:lexigram:conflict", "Conflict"
187 elif isinstance(exc, DomainError):
188 status_code, type_uri, title = (
189 400,
190 "urn:lexigram:bad-request",
191 "Bad Request",
192 )
194 if self._debug and self._html_renderer.should_render(request):
195 return self._html_renderer.render(exc, request, status_code=status_code)
197 pd = ProblemDetail(
198 type=type_uri,
199 title=title,
200 status=status_code,
201 detail=str(exc),
202 )
203 return JSONResponse(
204 content=pd.to_dict(),
205 status_code=status_code,
206 media_type="application/problem+json",
207 )
210__all__ = [
211 "DefaultExceptionFilter",
212 "DependencyResolutionFilter",
213 "ValidationErrorFilter",
214]