Coverage for src/lexigram/admin/core/middleware.py: 0%
116 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:43 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:43 +0800
1"""Error handling and middleware integration for lexigram-admin.
3This module provides error handling integration with lexigram.exceptions,
4and common middleware (compression, timing) for admin routes.
6FWK-05: ErrorHandler integration
7FWK-09: CompressionMiddleware, TimingMiddleware
8FWK-13: Entity base class
9"""
11from __future__ import annotations
13from dataclasses import dataclass, field
14from datetime import UTC, datetime
15from typing import TYPE_CHECKING, Any, TypeVar
17T = TypeVar("T")
20# ============================================================================
21# Try importing from lexigram
22# ============================================================================
24HAS_ERROR_HANDLER = True
25HAS_MIDDLEWARE = True
26HAS_ENTITY = True
29from lexigram.admin.exceptions import AdminError as CoreAdminError
31if TYPE_CHECKING:
32 from collections.abc import Callable
34# ============================================================================
35# Error Handler
36# ============================================================================
39@dataclass
40class ErrorResponse:
41 """Standard error response structure."""
43 error: str
44 message: str
45 status_code: int = 500
46 details: dict[str, Any] | None = None
47 timestamp: str = field(
48 default_factory=lambda: datetime.now(UTC).isoformat(),
49 )
50 request_id: str | None = None
53class AdminErrorHandler:
54 """Error handler for admin operations.
56 Provides consistent error handling and response formatting.
58 Example:
59 >>> handler = AdminErrorHandler()
60 >>> handler.register(ValidationError, AdminErrorHandler.handle_validation)
61 >>> response = handler.handle(error, request)
62 """
64 def __init__(self) -> None:
65 self._handlers: dict[
66 type[Exception],
67 Callable[[Exception, Any], ErrorResponse],
68 ] = {}
69 self._default_handler = self._handle_default
71 @classmethod
72 def with_defaults(cls) -> AdminErrorHandler:
73 """Return a new instance with default handlers registered."""
74 return cls()
76 def register(
77 self,
78 exception_type: type[Exception],
79 handler: Callable[[Exception, Any], ErrorResponse],
80 ) -> None:
81 """Register a handler for an exception type."""
82 self._handlers[exception_type] = handler
84 def handle(self, error: Exception, request: Any = None) -> ErrorResponse:
85 """Handle an exception and return error response."""
86 # Find matching handler
87 for exc_type, handler in self._handlers.items():
88 if isinstance(error, exc_type):
89 return handler(error, request)
91 return self._default_handler(error, request)
93 def _handle_default(self, error: Exception, request: Any) -> ErrorResponse:
94 """Default error handler."""
95 return ErrorResponse(
96 error=type(error).__name__,
97 message=str(error) or "An unexpected error occurred",
98 status_code=500,
99 )
101 @staticmethod
102 def handle_validation(error: Exception, _request: Any) -> ErrorResponse:
103 """Handler for validation errors."""
104 details = getattr(error, "errors", None)
105 return ErrorResponse(
106 error="ValidationError",
107 message=str(error),
108 status_code=422,
109 details=details,
110 )
112 @staticmethod
113 def handle_not_found(error: Exception, _request: Any) -> ErrorResponse:
114 """Handler for not found errors."""
115 return ErrorResponse(
116 error="NotFoundError",
117 message=str(error) or "Resource not found",
118 status_code=404,
119 )
121 @staticmethod
122 def handle_permission(error: Exception, _request: Any) -> ErrorResponse:
123 """Handler for permission errors."""
124 return ErrorResponse(
125 error="PermissionDenied",
126 message=str(error) or "Permission denied",
127 status_code=403,
128 )
130 @staticmethod
131 def handle_authentication(error: Exception, _request: Any) -> ErrorResponse:
132 """Handler for authentication errors."""
133 return ErrorResponse(
134 error="AuthenticationRequired",
135 message=str(error) or "Authentication required",
136 status_code=401,
137 )
140def with_admin_error_handling(
141 handler: AdminErrorHandler | None = None,
142) -> Callable[[Callable[..., T]], Callable[..., T]]:
143 """Decorator to add error handling to functions.
145 Example:
146 >>> @with_admin_error_handling()
147 ... async def create_user(data: dict) -> User:
148 ... return await User.create(**data)
149 """
150 from functools import wraps
152 _handler = handler or AdminErrorHandler.with_defaults()
154 def decorator(func: Callable[..., T]) -> Callable[..., T]:
155 @wraps(func)
156 async def wrapper(*args: Any, **kwargs: Any) -> T:
157 try:
158 return await func(*args, **kwargs) # type: ignore[misc]
159 except (ValueError, ConnectionError, TimeoutError, OSError) as e:
160 # Get request from args if available
161 request = args[0] if args else None
162 error_response = _handler.handle(e, request)
163 raise AdminError(error_response) from e
165 return wrapper # type: ignore[return-value]
167 return decorator
170class AdminError(CoreAdminError):
171 """Admin operation error with structured response."""
173 _code: str = "LEX_ERR_ADMIN_022"
175 def __init__(self, response: ErrorResponse, **kwargs: Any) -> None:
176 self.response = response
177 super().__init__(response.message, **kwargs)
180# ============================================================================
181# Entity Base Class
182# ============================================================================
185@dataclass
186class AdminEntity:
187 """Base entity class for admin models.
189 Provides common fields and timestamps for all admin entities.
191 Example:
192 >>> @dataclass
193 ... class User(AdminEntity):
194 ... email: str
195 ... name: str
196 """
198 id: int | str | None = None
199 created_at: datetime | None = None
200 updated_at: datetime | None = None
202 def __post_init__(self) -> None:
203 """Set timestamps on creation."""
204 if self.created_at is None:
205 self.created_at = datetime.now(UTC)
206 if self.updated_at is None:
207 self.updated_at = datetime.now(UTC)
209 def mark_updated(self) -> None:
210 """Update the updated_at timestamp."""
211 self.updated_at = datetime.now(UTC)
213 def to_dict(self) -> dict[str, Any]:
214 """Convert entity to dictionary."""
215 result: dict[str, Any] = {}
216 for key, value in self.__dict__.items():
217 if not key.startswith("_"):
218 if isinstance(value, datetime):
219 result[key] = value.isoformat()
220 else:
221 result[key] = value
222 return result
224 @classmethod
225 def from_dict(cls, data: dict[str, Any]) -> AdminEntity:
226 """Create entity from dictionary."""
227 # Convert datetime strings back to datetime
228 processed = {}
229 for key, value in data.items():
230 if key in ("created_at", "updated_at") and isinstance(value, str):
231 processed[key] = datetime.fromisoformat(value)
232 else:
233 processed[key] = value
234 return cls(**processed) # type: ignore[arg-type]
237@dataclass
238class SoftDeleteEntity(AdminEntity):
239 """Entity with soft delete support.
241 Example:
242 >>> @dataclass
243 ... class Document(SoftDeleteEntity):
244 ... title: str
245 ... content: str
246 """
248 deleted_at: datetime | None = None
249 deleted_by: int | str | None = None
251 @property
252 def is_deleted(self) -> bool:
253 """Check if entity is soft deleted."""
254 return self.deleted_at is not None
256 def soft_delete(self, deleted_by: int | str | None = None) -> None:
257 """Mark entity as deleted."""
258 self.deleted_at = datetime.now(UTC)
259 self.deleted_by = deleted_by
261 def restore(self) -> None:
262 """Restore soft deleted entity."""
263 self.deleted_at = None
264 self.deleted_by = None
267@dataclass
268class AuditedEntity(SoftDeleteEntity):
269 """Entity with full audit trail.
271 Example:
272 >>> @dataclass
273 ... class SecureDocument(AuditedEntity):
274 ... title: str
275 ... classification: str
276 """
278 created_by: int | str | None = None
279 updated_by: int | str | None = None
280 version: int = 1
282 def update(self, updated_by: int | str | None = None) -> None:
283 """Mark entity as updated with version increment."""
284 self.updated_at = datetime.now(UTC)
285 self.updated_by = updated_by
286 self.version += 1
289__all__ = [
290 # Flags
291 "HAS_ENTITY",
292 "HAS_ERROR_HANDLER",
293 "HAS_MIDDLEWARE",
294 # Entities
295 "AdminEntity",
296 "AdminError",
297 "AdminErrorHandler",
298 "AuditedEntity",
299 # Error handling
300 "ErrorResponse",
301 "SoftDeleteEntity",
302 "with_admin_error_handling",
303]