awesome-errors 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- awesome_errors/__init__.py +232 -0
- awesome_errors/analysis/__init__.py +8 -0
- awesome_errors/analysis/decorators.py +251 -0
- awesome_errors/analysis/error_analyzer.py +463 -0
- awesome_errors/client/__init__.py +7 -0
- awesome_errors/client/exceptions.py +97 -0
- awesome_errors/client/response_parser.py +175 -0
- awesome_errors/converters/__init__.py +31 -0
- awesome_errors/converters/generic.py +23 -0
- awesome_errors/converters/pydantic_converter.py +75 -0
- awesome_errors/converters/python_converter.py +80 -0
- awesome_errors/converters/sql_converter.py +258 -0
- awesome_errors/converters/universal_converter.py +123 -0
- awesome_errors/core/__init__.py +0 -0
- awesome_errors/core/error_codes.py +104 -0
- awesome_errors/core/error_response.py +61 -0
- awesome_errors/core/exceptions.py +613 -0
- awesome_errors/core/renderers.py +115 -0
- awesome_errors/i18n/__init__.py +0 -0
- awesome_errors/i18n/locales/en/errors.json +32 -0
- awesome_errors/i18n/locales/test/errors.json +4 -0
- awesome_errors/i18n/locales/uk/errors.json +26 -0
- awesome_errors/i18n/translator.py +150 -0
- awesome_errors/integrations/__init__.py +11 -0
- awesome_errors/integrations/fastapi_auto_docs.py +220 -0
- awesome_errors/litestar_utils.py +55 -0
- awesome_errors/middleware/__init__.py +0 -0
- awesome_errors/middleware/fastapi.py +247 -0
- awesome_errors/middleware/litestar.py +333 -0
- awesome_errors/py.typed +1 -0
- awesome_errors/websocket/__init__.py +35 -0
- awesome_errors/websocket/error_handler.py +231 -0
- awesome_errors/websocket/exceptions.py +286 -0
- awesome_errors-0.3.0.dist-info/METADATA +239 -0
- awesome_errors-0.3.0.dist-info/RECORD +37 -0
- awesome_errors-0.3.0.dist-info/WHEEL +4 -0
- awesome_errors-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Utilities that render ``AppError`` instances into HTTP payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any, Callable, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from ..core.exceptions import AppError
|
|
10
|
+
from .error_response import ErrorDetail, ErrorResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from datetime import timezone
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _isoformat(dt):
|
|
17
|
+
if dt.tzinfo is None:
|
|
18
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
19
|
+
else:
|
|
20
|
+
dt = dt.astimezone(timezone.utc)
|
|
21
|
+
return dt.isoformat().replace("+00:00", "Z")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ErrorResponseFormat(StrEnum):
|
|
25
|
+
"""Supported HTTP error payload shapes."""
|
|
26
|
+
|
|
27
|
+
LEGACY = "legacy"
|
|
28
|
+
RFC7807 = "rfc7807"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RenderResult:
|
|
33
|
+
payload: Dict[str, Any]
|
|
34
|
+
media_type: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ErrorResponseRenderer:
|
|
38
|
+
"""Render ``AppError`` objects into dictionaries ready for JSON encoding."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
format: ErrorResponseFormat = ErrorResponseFormat.LEGACY,
|
|
43
|
+
*,
|
|
44
|
+
problem_type_resolver: Optional[Callable[[AppError], str]] = None,
|
|
45
|
+
problem_extension_builder: Optional[
|
|
46
|
+
Callable[[AppError], Dict[str, Any]]
|
|
47
|
+
] = None,
|
|
48
|
+
):
|
|
49
|
+
self.format = format
|
|
50
|
+
self._problem_type_resolver = problem_type_resolver
|
|
51
|
+
self._problem_extension_builder = problem_extension_builder
|
|
52
|
+
|
|
53
|
+
def render(
|
|
54
|
+
self,
|
|
55
|
+
error: AppError,
|
|
56
|
+
*,
|
|
57
|
+
message: str,
|
|
58
|
+
request: Optional[Any] = None,
|
|
59
|
+
) -> RenderResult:
|
|
60
|
+
if self.format == ErrorResponseFormat.RFC7807:
|
|
61
|
+
return self._render_problem_detail(error, message=message, request=request)
|
|
62
|
+
return self._render_legacy(error, message=message)
|
|
63
|
+
|
|
64
|
+
def _render_legacy(self, error: AppError, *, message: str) -> RenderResult:
|
|
65
|
+
detail = ErrorDetail(
|
|
66
|
+
code=error.code.value,
|
|
67
|
+
message=message,
|
|
68
|
+
details=error.details,
|
|
69
|
+
timestamp=error.timestamp,
|
|
70
|
+
request_id=error.request_id or "unknown",
|
|
71
|
+
)
|
|
72
|
+
envelope = ErrorResponse(error=detail)
|
|
73
|
+
return RenderResult(payload=envelope.to_dict(), media_type="application/json")
|
|
74
|
+
|
|
75
|
+
def _render_problem_detail(
|
|
76
|
+
self,
|
|
77
|
+
error: AppError,
|
|
78
|
+
*,
|
|
79
|
+
message: str,
|
|
80
|
+
request: Optional[Any],
|
|
81
|
+
) -> RenderResult:
|
|
82
|
+
problem_type = (
|
|
83
|
+
self._problem_type_resolver(error)
|
|
84
|
+
if self._problem_type_resolver
|
|
85
|
+
else "about:blank"
|
|
86
|
+
)
|
|
87
|
+
instance: Optional[str] = None
|
|
88
|
+
if request is not None:
|
|
89
|
+
instance = getattr(request, "url", None)
|
|
90
|
+
if instance is not None:
|
|
91
|
+
instance = str(instance)
|
|
92
|
+
|
|
93
|
+
payload: Dict[str, Any] = {
|
|
94
|
+
"type": problem_type,
|
|
95
|
+
"title": error.message,
|
|
96
|
+
"status": error.status_code,
|
|
97
|
+
"detail": message,
|
|
98
|
+
"instance": instance,
|
|
99
|
+
"code": error.code.value,
|
|
100
|
+
"timestamp": _isoformat(error.timestamp),
|
|
101
|
+
"request_id": error.request_id,
|
|
102
|
+
"details": error.details,
|
|
103
|
+
}
|
|
104
|
+
extensions = (
|
|
105
|
+
self._problem_extension_builder(error)
|
|
106
|
+
if self._problem_extension_builder
|
|
107
|
+
else None
|
|
108
|
+
)
|
|
109
|
+
if extensions:
|
|
110
|
+
payload.update(extensions)
|
|
111
|
+
|
|
112
|
+
return RenderResult(
|
|
113
|
+
payload=payload,
|
|
114
|
+
media_type="application/problem+json",
|
|
115
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"UNKNOWN_ERROR": "An unknown error occurred",
|
|
3
|
+
"INTERNAL_ERROR": "Internal server error",
|
|
4
|
+
"VALIDATION_FAILED": "Validation failed",
|
|
5
|
+
"INVALID_INPUT": "Invalid input provided",
|
|
6
|
+
"MISSING_REQUIRED_FIELD": "Required field is missing",
|
|
7
|
+
"INVALID_FORMAT": "Invalid format",
|
|
8
|
+
"AUTH_REQUIRED": "Authentication required",
|
|
9
|
+
"AUTH_INVALID_TOKEN": "Invalid authentication token",
|
|
10
|
+
"AUTH_TOKEN_EXPIRED": "Authentication token has expired",
|
|
11
|
+
"AUTH_PERMISSION_DENIED": "Permission denied",
|
|
12
|
+
"AUTH_INSUFFICIENT_PRIVILEGES": "Insufficient privileges",
|
|
13
|
+
"RESOURCE_NOT_FOUND": "Resource not found",
|
|
14
|
+
"USER_NOT_FOUND": "User not found",
|
|
15
|
+
"ENTITY_NOT_FOUND": "Entity not found",
|
|
16
|
+
"DB_CONNECTION_ERROR": "Database connection error",
|
|
17
|
+
"DB_QUERY_ERROR": "Database query error",
|
|
18
|
+
"DB_CONSTRAINT_VIOLATION": "Database constraint violated",
|
|
19
|
+
"DB_DUPLICATE_ENTRY": "Duplicate entry",
|
|
20
|
+
"DB_INVALID_REFERENCE": "Invalid reference",
|
|
21
|
+
"DB_MISSING_REQUIRED": "Missing required field",
|
|
22
|
+
"BUSINESS_RULE_VIOLATION": "Business rule violated",
|
|
23
|
+
"INSUFFICIENT_BALANCE": "Insufficient balance",
|
|
24
|
+
"OPERATION_NOT_ALLOWED": "Operation not allowed",
|
|
25
|
+
"CUSTOM_ERROR": "Custom error in English",
|
|
26
|
+
"CUSTOM_ERROR_1": "First custom error",
|
|
27
|
+
"CUSTOM_ERROR_2": "Second custom error",
|
|
28
|
+
"USER_ERROR": "User {username} has {error_count} errors",
|
|
29
|
+
"TEMPLATE_ERROR": "User {username} has {missing_param} errors",
|
|
30
|
+
"SIMPLE_ERROR": "Simple error message",
|
|
31
|
+
"CUSTOM_TEST_ERROR": "Custom test error message"
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"UNKNOWN_ERROR": "Сталася невідома помилка",
|
|
3
|
+
"INTERNAL_ERROR": "Внутрішня помилка сервера",
|
|
4
|
+
"VALIDATION_FAILED": "Помилка валідації",
|
|
5
|
+
"INVALID_INPUT": "Недопустимі вхідні дані",
|
|
6
|
+
"MISSING_REQUIRED_FIELD": "Відсутнє обов'язкове поле",
|
|
7
|
+
"INVALID_FORMAT": "Невірний формат",
|
|
8
|
+
"AUTH_REQUIRED": "Потрібна автентифікація",
|
|
9
|
+
"AUTH_INVALID_TOKEN": "Недійсний токен автентифікації",
|
|
10
|
+
"AUTH_TOKEN_EXPIRED": "Термін дії токена закінчився",
|
|
11
|
+
"AUTH_PERMISSION_DENIED": "Доступ заборонено",
|
|
12
|
+
"AUTH_INSUFFICIENT_PRIVILEGES": "Недостатньо прав",
|
|
13
|
+
"RESOURCE_NOT_FOUND": "Ресурс не знайдено",
|
|
14
|
+
"USER_NOT_FOUND": "Користувача не знайдено",
|
|
15
|
+
"ENTITY_NOT_FOUND": "Об'єкт не знайдено",
|
|
16
|
+
"DB_CONNECTION_ERROR": "Помилка підключення до бази даних",
|
|
17
|
+
"DB_QUERY_ERROR": "Помилка запиту до бази даних",
|
|
18
|
+
"DB_CONSTRAINT_VIOLATION": "Порушено обмеження бази даних",
|
|
19
|
+
"DB_DUPLICATE_ENTRY": "Дублюючий запис",
|
|
20
|
+
"DB_INVALID_REFERENCE": "Недійсне посилання",
|
|
21
|
+
"DB_MISSING_REQUIRED": "Відсутнє обов'язкове поле",
|
|
22
|
+
"BUSINESS_RULE_VIOLATION": "Порушено бізнес-правило",
|
|
23
|
+
"INSUFFICIENT_BALANCE": "Недостатній баланс",
|
|
24
|
+
"OPERATION_NOT_ALLOWED": "Операція не дозволена",
|
|
25
|
+
"CUSTOM_TEST_ERROR": "Повідомлення про власну тестову помилку"
|
|
26
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, Optional, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ErrorTranslator:
|
|
7
|
+
"""Translator for error messages with i18n support."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, locales_dir: Optional[Path] = None, default_locale: str = "en"):
|
|
10
|
+
"""
|
|
11
|
+
Initialize translator.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
locales_dir: Directory containing locale files
|
|
15
|
+
default_locale: Default locale to use
|
|
16
|
+
"""
|
|
17
|
+
self.locales_dir = locales_dir or Path(__file__).parent / "locales"
|
|
18
|
+
self.default_locale = default_locale
|
|
19
|
+
self._translations: Dict[str, Dict[str, str]] = {}
|
|
20
|
+
self._load_translations()
|
|
21
|
+
|
|
22
|
+
def _load_translations(self) -> None:
|
|
23
|
+
"""Load all translation files."""
|
|
24
|
+
if not self.locales_dir.exists():
|
|
25
|
+
self.locales_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
# Create default English translations
|
|
27
|
+
self._create_default_translations()
|
|
28
|
+
|
|
29
|
+
for locale_file in self.locales_dir.glob("*/errors.json"):
|
|
30
|
+
locale = locale_file.parent.name
|
|
31
|
+
try:
|
|
32
|
+
with open(locale_file, "r", encoding="utf-8") as f:
|
|
33
|
+
self._translations[locale] = json.load(f)
|
|
34
|
+
except Exception:
|
|
35
|
+
# Skip invalid files
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def _create_default_translations(self) -> None:
|
|
39
|
+
"""Create default English translations."""
|
|
40
|
+
en_dir = self.locales_dir / "en"
|
|
41
|
+
en_dir.mkdir(exist_ok=True)
|
|
42
|
+
|
|
43
|
+
default_translations = {
|
|
44
|
+
# General errors
|
|
45
|
+
"UNKNOWN_ERROR": "An unknown error occurred",
|
|
46
|
+
"INTERNAL_ERROR": "Internal server error",
|
|
47
|
+
# Validation errors
|
|
48
|
+
"VALIDATION_FAILED": "Validation failed",
|
|
49
|
+
"INVALID_INPUT": "Invalid input provided",
|
|
50
|
+
"MISSING_REQUIRED_FIELD": "Required field is missing",
|
|
51
|
+
"INVALID_FORMAT": "Invalid format",
|
|
52
|
+
# Authentication errors
|
|
53
|
+
"AUTH_REQUIRED": "Authentication required",
|
|
54
|
+
"AUTH_INVALID_TOKEN": "Invalid authentication token",
|
|
55
|
+
"AUTH_TOKEN_EXPIRED": "Authentication token has expired",
|
|
56
|
+
# Authorization errors
|
|
57
|
+
"AUTH_PERMISSION_DENIED": "Permission denied",
|
|
58
|
+
"AUTH_INSUFFICIENT_PRIVILEGES": "Insufficient privileges",
|
|
59
|
+
# Not found errors
|
|
60
|
+
"RESOURCE_NOT_FOUND": "Resource not found",
|
|
61
|
+
"USER_NOT_FOUND": "User not found",
|
|
62
|
+
"ENTITY_NOT_FOUND": "Entity not found",
|
|
63
|
+
# Database errors
|
|
64
|
+
"DB_CONNECTION_ERROR": "Database connection error",
|
|
65
|
+
"DB_QUERY_ERROR": "Database query error",
|
|
66
|
+
"DB_CONSTRAINT_VIOLATION": "Database constraint violated",
|
|
67
|
+
"DB_DUPLICATE_ENTRY": "Duplicate entry",
|
|
68
|
+
"DB_INVALID_REFERENCE": "Invalid reference",
|
|
69
|
+
"DB_MISSING_REQUIRED": "Missing required field",
|
|
70
|
+
# Business logic errors
|
|
71
|
+
"BUSINESS_RULE_VIOLATION": "Business rule violated",
|
|
72
|
+
"INSUFFICIENT_BALANCE": "Insufficient balance",
|
|
73
|
+
"OPERATION_NOT_ALLOWED": "Operation not allowed",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
with open(en_dir / "errors.json", "w", encoding="utf-8") as f:
|
|
77
|
+
json.dump(default_translations, f, indent=2, ensure_ascii=False)
|
|
78
|
+
|
|
79
|
+
self._translations["en"] = default_translations
|
|
80
|
+
|
|
81
|
+
def translate(
|
|
82
|
+
self,
|
|
83
|
+
error_code: str,
|
|
84
|
+
locale: Optional[str] = None,
|
|
85
|
+
params: Optional[Dict[str, Any]] = None,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""
|
|
88
|
+
Translate error code to message.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
error_code: Error code to translate
|
|
92
|
+
locale: Locale to use (e.g., 'en', 'uk')
|
|
93
|
+
params: Parameters for message formatting
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Translated message
|
|
97
|
+
"""
|
|
98
|
+
locale = locale or self.default_locale
|
|
99
|
+
|
|
100
|
+
# Try requested locale
|
|
101
|
+
if locale in self._translations:
|
|
102
|
+
message = self._translations[locale].get(error_code)
|
|
103
|
+
if message:
|
|
104
|
+
return self._format_message(message, params)
|
|
105
|
+
|
|
106
|
+
# Fallback to English if available and different from requested locale (case sensitive)
|
|
107
|
+
if "en" in self._translations and locale.lower() != "en":
|
|
108
|
+
message = self._translations["en"].get(error_code)
|
|
109
|
+
if message:
|
|
110
|
+
return self._format_message(message, params)
|
|
111
|
+
|
|
112
|
+
# Return error code if no translation found
|
|
113
|
+
return error_code
|
|
114
|
+
|
|
115
|
+
def _format_message(self, message: str, params: Optional[Dict[str, Any]]) -> str:
|
|
116
|
+
"""Format message with parameters."""
|
|
117
|
+
if not params:
|
|
118
|
+
return message
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
return message.format(**params)
|
|
122
|
+
except Exception:
|
|
123
|
+
# Return unformatted message if formatting fails
|
|
124
|
+
return message
|
|
125
|
+
|
|
126
|
+
def add_translations(
|
|
127
|
+
self, locale: str, translations: Dict[str, str], *, persist: bool = True
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Add or update translations for a locale.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
locale: Locale identifier
|
|
133
|
+
translations: Mapping of error codes to translated messages
|
|
134
|
+
persist: Persist changes to disk. Set to ``False`` for ephemeral usage.
|
|
135
|
+
"""
|
|
136
|
+
if locale not in self._translations:
|
|
137
|
+
self._translations[locale] = {}
|
|
138
|
+
|
|
139
|
+
self._translations[locale].update(translations)
|
|
140
|
+
|
|
141
|
+
if persist:
|
|
142
|
+
locale_dir = self.locales_dir / locale
|
|
143
|
+
locale_dir.mkdir(exist_ok=True)
|
|
144
|
+
|
|
145
|
+
with open(locale_dir / "errors.json", "w", encoding="utf-8") as f:
|
|
146
|
+
json.dump(self._translations[locale], f, indent=2, ensure_ascii=False)
|
|
147
|
+
|
|
148
|
+
def get_available_locales(self) -> list[str]:
|
|
149
|
+
"""Get list of available locales."""
|
|
150
|
+
return list(self._translations.keys())
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI automatic error documentation integration.
|
|
3
|
+
Automatically analyzes routes and adds error response schemas to OpenAPI docs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def auto_analyze_errors(func):
|
|
12
|
+
from ..analysis.decorators import analyze_errors
|
|
13
|
+
|
|
14
|
+
decorated_func = analyze_errors()(func)
|
|
15
|
+
decorated_func._auto_openapi = True
|
|
16
|
+
return decorated_func
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup_automatic_error_docs(app, **kwargs):
|
|
20
|
+
"""
|
|
21
|
+
Setup automatic error documentation for all FastAPI routes.
|
|
22
|
+
|
|
23
|
+
This function analyzes all route functions using AST analysis to find
|
|
24
|
+
possible errors and automatically adds comprehensive error response
|
|
25
|
+
schemas to the OpenAPI documentation.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
app: FastAPI application instance
|
|
29
|
+
**kwargs: Additional configuration options
|
|
30
|
+
- include_http_exceptions: Whether to analyze HTTPException (default: True)
|
|
31
|
+
- max_depth: Maximum analysis depth for function calls (default: 3)
|
|
32
|
+
- exclude_paths: List of path patterns to exclude from analysis
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from fastapi import FastAPI
|
|
36
|
+
|
|
37
|
+
if not isinstance(app, FastAPI):
|
|
38
|
+
logger.warning("setup_automatic_error_docs expects a FastAPI app instance")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
logger.info("Setting up automatic error documentation for FastAPI routes...")
|
|
42
|
+
|
|
43
|
+
# Apply to all routes in the app
|
|
44
|
+
_apply_auto_error_docs_to_app(app, **kwargs)
|
|
45
|
+
|
|
46
|
+
logger.info("Automatic error documentation setup complete")
|
|
47
|
+
|
|
48
|
+
except ImportError:
|
|
49
|
+
logger.warning("FastAPI not available, skipping automatic error documentation")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error(f"Failed to setup automatic error documentation: {e}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _apply_auto_error_docs_to_app(app, **kwargs):
|
|
55
|
+
"""Apply automatic error documentation to all routes in FastAPI app."""
|
|
56
|
+
from ..analysis.error_analyzer import ErrorAnalyzer
|
|
57
|
+
from ..analysis.decorators import _generate_openapi_responses
|
|
58
|
+
|
|
59
|
+
exclude_paths = kwargs.get("exclude_paths", [])
|
|
60
|
+
max_depth = kwargs.get("max_depth", 3)
|
|
61
|
+
|
|
62
|
+
routes_processed = 0
|
|
63
|
+
errors_found = 0
|
|
64
|
+
|
|
65
|
+
for route in app.routes:
|
|
66
|
+
# Skip if path is in exclude list
|
|
67
|
+
if any(pattern in route.path for pattern in exclude_paths):
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if hasattr(route, "endpoint") and hasattr(route, "methods"):
|
|
71
|
+
try:
|
|
72
|
+
endpoint_func = route.endpoint
|
|
73
|
+
|
|
74
|
+
# Analyze the function for possible errors
|
|
75
|
+
analyzer = ErrorAnalyzer(
|
|
76
|
+
endpoint_func, max_depth=max_depth, analyze_decorators=True
|
|
77
|
+
)
|
|
78
|
+
analysis = analyzer.analyze()
|
|
79
|
+
error_codes = analysis.get("error_codes", set())
|
|
80
|
+
|
|
81
|
+
if error_codes:
|
|
82
|
+
# Generate OpenAPI responses
|
|
83
|
+
openapi_responses = _generate_openapi_responses(error_codes, {})
|
|
84
|
+
|
|
85
|
+
# Apply responses to the route
|
|
86
|
+
if not hasattr(route, "responses"):
|
|
87
|
+
route.responses = {}
|
|
88
|
+
|
|
89
|
+
# Merge with existing responses (don't overwrite existing)
|
|
90
|
+
for status_code, response_schema in openapi_responses.items():
|
|
91
|
+
if status_code not in route.responses:
|
|
92
|
+
route.responses[status_code] = response_schema
|
|
93
|
+
|
|
94
|
+
routes_processed += 1
|
|
95
|
+
errors_found += len(error_codes)
|
|
96
|
+
|
|
97
|
+
logger.debug(
|
|
98
|
+
f"Applied error responses to {route.path} "
|
|
99
|
+
f"({', '.join(route.methods)}): {list(openapi_responses.keys())}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.warning(f"Failed to analyze errors for route {route.path}: {e}")
|
|
104
|
+
|
|
105
|
+
# Also handle included routers
|
|
106
|
+
elif hasattr(route, "app") and hasattr(route.app, "routes"):
|
|
107
|
+
_process_sub_routes(route.app.routes, exclude_paths, max_depth)
|
|
108
|
+
|
|
109
|
+
logger.info(
|
|
110
|
+
f"Processed {routes_processed} routes, found {errors_found} total error codes"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _process_sub_routes(routes, exclude_paths, max_depth):
|
|
115
|
+
"""Process routes in sub-applications/routers."""
|
|
116
|
+
from ..analysis.error_analyzer import ErrorAnalyzer
|
|
117
|
+
from ..analysis.decorators import _generate_openapi_responses
|
|
118
|
+
|
|
119
|
+
for sub_route in routes:
|
|
120
|
+
# Skip if path is in exclude list
|
|
121
|
+
if any(pattern in sub_route.path for pattern in exclude_paths):
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if hasattr(sub_route, "endpoint") and hasattr(sub_route, "methods"):
|
|
125
|
+
try:
|
|
126
|
+
endpoint_func = sub_route.endpoint
|
|
127
|
+
|
|
128
|
+
analyzer = ErrorAnalyzer(
|
|
129
|
+
endpoint_func, max_depth=max_depth, analyze_decorators=True
|
|
130
|
+
)
|
|
131
|
+
analysis = analyzer.analyze()
|
|
132
|
+
error_codes = analysis.get("error_codes", set())
|
|
133
|
+
|
|
134
|
+
if error_codes:
|
|
135
|
+
openapi_responses = _generate_openapi_responses(error_codes, {})
|
|
136
|
+
|
|
137
|
+
if not hasattr(sub_route, "responses"):
|
|
138
|
+
sub_route.responses = {}
|
|
139
|
+
|
|
140
|
+
# Merge with existing responses
|
|
141
|
+
for status_code, response_schema in openapi_responses.items():
|
|
142
|
+
if status_code not in sub_route.responses:
|
|
143
|
+
sub_route.responses[status_code] = response_schema
|
|
144
|
+
|
|
145
|
+
logger.debug(
|
|
146
|
+
f"Applied error responses to sub-route {sub_route.path}: "
|
|
147
|
+
f"{list(openapi_responses.keys())}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.warning(
|
|
152
|
+
f"Failed to analyze errors for sub-route {sub_route.path}: {e}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def apply_auto_error_docs_to_router(router, **kwargs):
|
|
157
|
+
"""
|
|
158
|
+
Apply automatic error documentation to a specific FastAPI router.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
router: FastAPI APIRouter instance
|
|
162
|
+
**kwargs: Configuration options (same as setup_automatic_error_docs)
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
from fastapi import APIRouter
|
|
166
|
+
|
|
167
|
+
if not isinstance(router, APIRouter):
|
|
168
|
+
logger.warning(
|
|
169
|
+
"apply_auto_error_docs_to_router expects an APIRouter instance"
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
_apply_auto_error_docs_to_routes(router.routes, **kwargs)
|
|
174
|
+
|
|
175
|
+
except ImportError:
|
|
176
|
+
logger.warning("FastAPI not available for router error documentation")
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error(f"Failed to apply error docs to router: {e}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _apply_auto_error_docs_to_routes(routes, **kwargs):
|
|
182
|
+
"""Apply automatic error documentation to a list of routes."""
|
|
183
|
+
from ..analysis.error_analyzer import ErrorAnalyzer
|
|
184
|
+
from ..analysis.decorators import _generate_openapi_responses
|
|
185
|
+
|
|
186
|
+
exclude_paths = kwargs.get("exclude_paths", [])
|
|
187
|
+
max_depth = kwargs.get("max_depth", 3)
|
|
188
|
+
|
|
189
|
+
for route in routes:
|
|
190
|
+
# Skip if path is in exclude list
|
|
191
|
+
if any(pattern in route.path for pattern in exclude_paths):
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
if hasattr(route, "endpoint") and hasattr(route, "methods"):
|
|
195
|
+
try:
|
|
196
|
+
endpoint_func = route.endpoint
|
|
197
|
+
|
|
198
|
+
analyzer = ErrorAnalyzer(
|
|
199
|
+
endpoint_func, max_depth=max_depth, analyze_decorators=True
|
|
200
|
+
)
|
|
201
|
+
analysis = analyzer.analyze()
|
|
202
|
+
error_codes = analysis.get("error_codes", set())
|
|
203
|
+
|
|
204
|
+
if error_codes:
|
|
205
|
+
openapi_responses = _generate_openapi_responses(error_codes, {})
|
|
206
|
+
|
|
207
|
+
if not hasattr(route, "responses"):
|
|
208
|
+
route.responses = {}
|
|
209
|
+
|
|
210
|
+
# Merge with existing responses
|
|
211
|
+
for status_code, response_schema in openapi_responses.items():
|
|
212
|
+
if status_code not in route.responses:
|
|
213
|
+
route.responses[status_code] = response_schema
|
|
214
|
+
|
|
215
|
+
logger.debug(
|
|
216
|
+
f"Applied error responses to {route.path}: {list(openapi_responses.keys())}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.warning(f"Failed to analyze errors for route {route.path}: {e}")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Litestar-specific helpers for API error metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable, Tuple, Type, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from .core.exceptions import APIError
|
|
8
|
+
|
|
9
|
+
ErrorType = TypeVar("ErrorType", bound=APIError)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _is_http_route_handler(obj: object) -> bool:
|
|
13
|
+
try: # pragma: no cover - optional dependency
|
|
14
|
+
from litestar.handlers.http_handlers import HTTPRouteHandler
|
|
15
|
+
except ImportError: # pragma: no cover
|
|
16
|
+
return False
|
|
17
|
+
return isinstance(obj, HTTPRouteHandler)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def raises_from(*errors: Type[APIError]) -> list[Type[APIError]]:
|
|
21
|
+
"""Return a Litestar-compatible raises list from APIError classes."""
|
|
22
|
+
return list(errors)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def errors(*errs: Type[APIError]):
|
|
26
|
+
"""Attach OpenAPI error responses to a Litestar handler."""
|
|
27
|
+
|
|
28
|
+
def wrap(obj: Any) -> Any:
|
|
29
|
+
if _is_http_route_handler(obj):
|
|
30
|
+
current = getattr(obj, "raises", None) or []
|
|
31
|
+
if isinstance(current, dict):
|
|
32
|
+
current = []
|
|
33
|
+
obj.raises = cast(Any, [*list(current), *errs])
|
|
34
|
+
return obj
|
|
35
|
+
setattr(obj, "__api_errors__", errs)
|
|
36
|
+
return obj
|
|
37
|
+
|
|
38
|
+
return wrap
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def apply_api_errors(handlers: Iterable[object]) -> None:
|
|
42
|
+
"""Apply deferred error metadata to handlers (optional helper)."""
|
|
43
|
+
for handler in handlers:
|
|
44
|
+
if not _is_http_route_handler(handler):
|
|
45
|
+
continue
|
|
46
|
+
typed_handler = cast(Any, handler)
|
|
47
|
+
errs: Tuple[Type[APIError], ...] | None = getattr(
|
|
48
|
+
typed_handler.fn, "__api_errors__", None
|
|
49
|
+
)
|
|
50
|
+
if not errs:
|
|
51
|
+
continue
|
|
52
|
+
current = typed_handler.raises or []
|
|
53
|
+
if isinstance(current, dict):
|
|
54
|
+
current = []
|
|
55
|
+
typed_handler.raises = cast(Any, [*list(current), *errs])
|
|
File without changes
|