global-api-tools 0.1.1__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.
- global_api_tools/__init__.py +27 -0
- global_api_tools/_compat.py +19 -0
- global_api_tools/exceptions.py +369 -0
- global_api_tools/logging.py +63 -0
- global_api_tools/responses.py +178 -0
- global_api_tools-0.1.1.dist-info/METADATA +405 -0
- global_api_tools-0.1.1.dist-info/RECORD +10 -0
- global_api_tools-0.1.1.dist-info/WHEEL +5 -0
- global_api_tools-0.1.1.dist-info/licenses/LICENSE +21 -0
- global_api_tools-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .exceptions import (
|
|
2
|
+
ApiError,
|
|
3
|
+
ErrorHandler,
|
|
4
|
+
ExceptionHandler,
|
|
5
|
+
explain_error,
|
|
6
|
+
get_status_code,
|
|
7
|
+
handle_exception,
|
|
8
|
+
register_exception_handlers,
|
|
9
|
+
unified_exception_handler,
|
|
10
|
+
)
|
|
11
|
+
from .logging import get_logger, logs
|
|
12
|
+
from .responses import create_response, value_correction
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ApiError",
|
|
16
|
+
"ErrorHandler",
|
|
17
|
+
"ExceptionHandler",
|
|
18
|
+
"create_response",
|
|
19
|
+
"explain_error",
|
|
20
|
+
"get_logger",
|
|
21
|
+
"get_status_code",
|
|
22
|
+
"handle_exception",
|
|
23
|
+
"logs",
|
|
24
|
+
"register_exception_handlers",
|
|
25
|
+
"unified_exception_handler",
|
|
26
|
+
"value_correction",
|
|
27
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def optional_import(module_name: str, attribute: str | None = None) -> Any:
|
|
8
|
+
try:
|
|
9
|
+
module = importlib.import_module(module_name)
|
|
10
|
+
except ImportError:
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
if attribute is None:
|
|
14
|
+
return module
|
|
15
|
+
return getattr(module, attribute, None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compact_types(*maybe_types: Any) -> tuple[type[Any], ...]:
|
|
19
|
+
return tuple(item for item in maybe_types if isinstance(item, type))
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._compat import compact_types, optional_import
|
|
8
|
+
from .logging import get_logger
|
|
9
|
+
|
|
10
|
+
FastAPIHTTPException = optional_import("fastapi", "HTTPException")
|
|
11
|
+
RequestValidationError = optional_import("fastapi.exceptions", "RequestValidationError")
|
|
12
|
+
JSONResponse = optional_import("fastapi.responses", "JSONResponse")
|
|
13
|
+
StarletteHTTPException = optional_import("starlette.exceptions", "HTTPException")
|
|
14
|
+
PydanticValidationError = optional_import("pydantic", "ValidationError")
|
|
15
|
+
SQLAlchemyError = optional_import("sqlalchemy.exc", "SQLAlchemyError")
|
|
16
|
+
IntegrityError = optional_import("sqlalchemy.exc", "IntegrityError")
|
|
17
|
+
OperationalError = optional_import("sqlalchemy.exc", "OperationalError")
|
|
18
|
+
ProgrammingError = optional_import("sqlalchemy.exc", "ProgrammingError")
|
|
19
|
+
DataError = optional_import("sqlalchemy.exc", "DataError")
|
|
20
|
+
DisconnectionError = optional_import("sqlalchemy.exc", "DisconnectionError")
|
|
21
|
+
PsycopgErrors = optional_import("psycopg2", "errors")
|
|
22
|
+
AsyncPGExceptions = optional_import("asyncpg", "exceptions")
|
|
23
|
+
|
|
24
|
+
AsyncPGDataError = getattr(AsyncPGExceptions, "DataError", None) if AsyncPGExceptions else None
|
|
25
|
+
NotNullViolation = getattr(PsycopgErrors, "NotNullViolation", None) if PsycopgErrors else None
|
|
26
|
+
UniqueViolation = getattr(PsycopgErrors, "UniqueViolation", None) if PsycopgErrors else None
|
|
27
|
+
ForeignKeyViolation = getattr(PsycopgErrors, "ForeignKeyViolation", None) if PsycopgErrors else None
|
|
28
|
+
CheckViolation = getattr(PsycopgErrors, "CheckViolation", None) if PsycopgErrors else None
|
|
29
|
+
UndefinedTable = getattr(PsycopgErrors, "UndefinedTable", None) if PsycopgErrors else None
|
|
30
|
+
|
|
31
|
+
HTTP_EXCEPTION_TYPES = compact_types(FastAPIHTTPException, StarletteHTTPException)
|
|
32
|
+
VALIDATION_EXCEPTION_TYPES = compact_types(RequestValidationError, PydanticValidationError)
|
|
33
|
+
INTEGRITY_EXCEPTION_TYPES = compact_types(
|
|
34
|
+
IntegrityError,
|
|
35
|
+
UniqueViolation,
|
|
36
|
+
ForeignKeyViolation,
|
|
37
|
+
NotNullViolation,
|
|
38
|
+
CheckViolation,
|
|
39
|
+
)
|
|
40
|
+
DATA_EXCEPTION_TYPES = compact_types(DataError, AsyncPGDataError)
|
|
41
|
+
DB_UNAVAILABLE_EXCEPTION_TYPES = compact_types(OperationalError, DisconnectionError, ConnectionError)
|
|
42
|
+
PROGRAMMING_EXCEPTION_TYPES = compact_types(ProgrammingError, UndefinedTable)
|
|
43
|
+
GENERIC_DATABASE_EXCEPTION_TYPES = compact_types(SQLAlchemyError)
|
|
44
|
+
|
|
45
|
+
DEFAULT_STATUS_MESSAGES = {
|
|
46
|
+
400: "The request data is invalid.",
|
|
47
|
+
401: "Authentication is required to access this resource.",
|
|
48
|
+
403: "You do not have permission to perform this action.",
|
|
49
|
+
404: "The requested resource was not found.",
|
|
50
|
+
409: "The request conflicts with existing data.",
|
|
51
|
+
422: "One or more fields are invalid.",
|
|
52
|
+
429: "Too many requests were sent in a short time.",
|
|
53
|
+
500: "An unexpected error occurred.",
|
|
54
|
+
503: "A required service is temporarily unavailable.",
|
|
55
|
+
504: "A required service took too long to respond.",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ErrorInfo:
|
|
61
|
+
status_code: int
|
|
62
|
+
code: str
|
|
63
|
+
message: str
|
|
64
|
+
details: list[dict[str, Any]] | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ApiError(Exception):
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
message: str,
|
|
71
|
+
*,
|
|
72
|
+
status_code: int = 400,
|
|
73
|
+
code: str = "api_error",
|
|
74
|
+
details: list[dict[str, Any]] | None = None,
|
|
75
|
+
log_message: str | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.message = message
|
|
79
|
+
self.status_code = status_code
|
|
80
|
+
self.code = code
|
|
81
|
+
self.details = details
|
|
82
|
+
self.log_message = log_message
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _default_message(status_code: int) -> str:
|
|
86
|
+
return DEFAULT_STATUS_MESSAGES.get(status_code, "An unexpected error occurred.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _clean_message(message: str) -> str:
|
|
90
|
+
clean = message.strip().strip('"').strip("'")
|
|
91
|
+
if not clean:
|
|
92
|
+
return ""
|
|
93
|
+
if "\n" in clean or len(clean) > 240:
|
|
94
|
+
return ""
|
|
95
|
+
return clean
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _safe_client_message(exc: Exception, default: str) -> str:
|
|
99
|
+
return _clean_message(str(exc)) or default
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_request_value(request: Any, attribute: str) -> Any:
|
|
103
|
+
if request is None:
|
|
104
|
+
return None
|
|
105
|
+
return getattr(request, attribute, None)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _extract_request_id(request: Any) -> str | None:
|
|
109
|
+
headers = _extract_request_value(request, "headers")
|
|
110
|
+
if headers is None:
|
|
111
|
+
return None
|
|
112
|
+
return headers.get("x-request-id") or headers.get("x-correlation-id")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _normalize_validation_errors(exc: Exception) -> list[dict[str, str]]:
|
|
116
|
+
if not hasattr(exc, "errors"):
|
|
117
|
+
return [{"field": "data", "message": _safe_client_message(exc, "Validation failed.")}]
|
|
118
|
+
|
|
119
|
+
normalized: list[dict[str, str]] = []
|
|
120
|
+
for error in exc.errors(): # type: ignore[attr-defined]
|
|
121
|
+
location = [str(part) for part in error.get("loc", []) if part != "__root__"]
|
|
122
|
+
if location and location[0] in {"body", "query", "path"}:
|
|
123
|
+
location = location[1:]
|
|
124
|
+
normalized.append(
|
|
125
|
+
{
|
|
126
|
+
"field": ".".join(location) if location else "data",
|
|
127
|
+
"message": error.get("msg") or error.get("message") or "Invalid value.",
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
return normalized
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _database_message(exc: Exception) -> str:
|
|
134
|
+
original = getattr(exc, "orig", None)
|
|
135
|
+
raw_message = str(original or exc).lower()
|
|
136
|
+
|
|
137
|
+
duplicate_match = re.search(r"key\s*\((?P<field>[^)]+)\)=", raw_message)
|
|
138
|
+
foreign_key_match = re.search(r"key\s*\((?P<field>[^)]+)\)=", raw_message)
|
|
139
|
+
null_match = re.search(r'column\s+"(?P<field>[^"]+)"', raw_message)
|
|
140
|
+
|
|
141
|
+
if "duplicate key" in raw_message or "unique" in raw_message:
|
|
142
|
+
if duplicate_match:
|
|
143
|
+
return f"A record with this {duplicate_match.group('field')} already exists."
|
|
144
|
+
return "A record with the same value already exists."
|
|
145
|
+
if "foreign key" in raw_message:
|
|
146
|
+
if foreign_key_match:
|
|
147
|
+
return f"The related value for {foreign_key_match.group('field')} does not exist."
|
|
148
|
+
return "A related record was not found."
|
|
149
|
+
if "not-null" in raw_message or "cannot be null" in raw_message:
|
|
150
|
+
if null_match:
|
|
151
|
+
return f"The field {null_match.group('field')} is required."
|
|
152
|
+
return "A required field is missing."
|
|
153
|
+
if "check constraint" in raw_message:
|
|
154
|
+
return "One or more fields failed a database validation rule."
|
|
155
|
+
if "out of range" in raw_message:
|
|
156
|
+
return "A numeric value is outside the allowed range."
|
|
157
|
+
if "invalid input syntax" in raw_message or "expected" in raw_message:
|
|
158
|
+
return "One or more fields have the wrong type or format."
|
|
159
|
+
return "A database error occurred while processing the request."
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _database_status(exc: Exception) -> tuple[int, str]:
|
|
163
|
+
raw_message = str(getattr(exc, "orig", exc)).lower()
|
|
164
|
+
|
|
165
|
+
if "duplicate key" in raw_message or "unique" in raw_message:
|
|
166
|
+
return 409, "duplicate_resource"
|
|
167
|
+
if "foreign key" in raw_message:
|
|
168
|
+
return 422, "invalid_reference"
|
|
169
|
+
if "not-null" in raw_message or "cannot be null" in raw_message:
|
|
170
|
+
return 422, "missing_required_field"
|
|
171
|
+
if "check constraint" in raw_message:
|
|
172
|
+
return 422, "constraint_violation"
|
|
173
|
+
if isinstance(exc, DATA_EXCEPTION_TYPES):
|
|
174
|
+
return 422, "invalid_data"
|
|
175
|
+
if isinstance(exc, DB_UNAVAILABLE_EXCEPTION_TYPES):
|
|
176
|
+
return 503, "database_unavailable"
|
|
177
|
+
if isinstance(exc, PROGRAMMING_EXCEPTION_TYPES):
|
|
178
|
+
return 500, "database_programming_error"
|
|
179
|
+
return 500, "database_error"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ErrorHandler:
|
|
183
|
+
def __init__(self, *, logger_name: str = "global_api_tools.errors") -> None:
|
|
184
|
+
self.logger = get_logger(logger_name)
|
|
185
|
+
|
|
186
|
+
def describe(self, exc: Exception) -> ErrorInfo:
|
|
187
|
+
if isinstance(exc, ApiError):
|
|
188
|
+
return ErrorInfo(
|
|
189
|
+
status_code=exc.status_code,
|
|
190
|
+
code=exc.code,
|
|
191
|
+
message=exc.message,
|
|
192
|
+
details=exc.details,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if HTTP_EXCEPTION_TYPES and isinstance(exc, HTTP_EXCEPTION_TYPES):
|
|
196
|
+
status_code = getattr(exc, "status_code", 500)
|
|
197
|
+
detail = getattr(exc, "detail", None)
|
|
198
|
+
if status_code >= 500:
|
|
199
|
+
message = _default_message(status_code)
|
|
200
|
+
elif isinstance(detail, str):
|
|
201
|
+
message = _clean_message(detail) or _default_message(status_code)
|
|
202
|
+
else:
|
|
203
|
+
message = _default_message(status_code)
|
|
204
|
+
return ErrorInfo(status_code=status_code, code=f"http_{status_code}", message=message)
|
|
205
|
+
|
|
206
|
+
if VALIDATION_EXCEPTION_TYPES and isinstance(exc, VALIDATION_EXCEPTION_TYPES):
|
|
207
|
+
return ErrorInfo(
|
|
208
|
+
status_code=422,
|
|
209
|
+
code="validation_error",
|
|
210
|
+
message=_default_message(422),
|
|
211
|
+
details=_normalize_validation_errors(exc),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if INTEGRITY_EXCEPTION_TYPES and isinstance(exc, INTEGRITY_EXCEPTION_TYPES):
|
|
215
|
+
status_code, code = _database_status(exc)
|
|
216
|
+
return ErrorInfo(status_code=status_code, code=code, message=_database_message(exc))
|
|
217
|
+
|
|
218
|
+
if DATA_EXCEPTION_TYPES and isinstance(exc, DATA_EXCEPTION_TYPES):
|
|
219
|
+
return ErrorInfo(
|
|
220
|
+
status_code=422,
|
|
221
|
+
code="invalid_data",
|
|
222
|
+
message="One or more fields have the wrong type or format.",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if DB_UNAVAILABLE_EXCEPTION_TYPES and isinstance(exc, DB_UNAVAILABLE_EXCEPTION_TYPES):
|
|
226
|
+
if isinstance(exc, TimeoutError):
|
|
227
|
+
return ErrorInfo(status_code=504, code="upstream_timeout", message=_default_message(504))
|
|
228
|
+
return ErrorInfo(status_code=503, code="service_unavailable", message=_default_message(503))
|
|
229
|
+
|
|
230
|
+
if GENERIC_DATABASE_EXCEPTION_TYPES and isinstance(exc, GENERIC_DATABASE_EXCEPTION_TYPES):
|
|
231
|
+
status_code, code = _database_status(exc)
|
|
232
|
+
message = _database_message(exc)
|
|
233
|
+
if status_code >= 500:
|
|
234
|
+
message = _default_message(status_code)
|
|
235
|
+
return ErrorInfo(status_code=status_code, code=code, message=message)
|
|
236
|
+
|
|
237
|
+
if isinstance(exc, FileNotFoundError):
|
|
238
|
+
return ErrorInfo(status_code=404, code="resource_not_found", message=_default_message(404))
|
|
239
|
+
|
|
240
|
+
if isinstance(exc, PermissionError):
|
|
241
|
+
return ErrorInfo(status_code=403, code="forbidden", message=_default_message(403))
|
|
242
|
+
|
|
243
|
+
if isinstance(exc, TimeoutError):
|
|
244
|
+
return ErrorInfo(status_code=504, code="upstream_timeout", message=_default_message(504))
|
|
245
|
+
|
|
246
|
+
if isinstance(exc, ConnectionError):
|
|
247
|
+
return ErrorInfo(status_code=503, code="service_unavailable", message=_default_message(503))
|
|
248
|
+
|
|
249
|
+
if isinstance(exc, MemoryError):
|
|
250
|
+
return ErrorInfo(status_code=503, code="service_unavailable", message=_default_message(503))
|
|
251
|
+
|
|
252
|
+
if isinstance(exc, (ValueError, TypeError, KeyError, IndexError, AssertionError)):
|
|
253
|
+
return ErrorInfo(
|
|
254
|
+
status_code=400,
|
|
255
|
+
code="bad_request",
|
|
256
|
+
message=_safe_client_message(exc, _default_message(400)),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return ErrorInfo(status_code=500, code="internal_error", message=_default_message(500))
|
|
260
|
+
|
|
261
|
+
def log_exception(self, exc: Exception, request: Any = None) -> None:
|
|
262
|
+
method = _extract_request_value(request, "method")
|
|
263
|
+
url = _extract_request_value(request, "url")
|
|
264
|
+
path = getattr(url, "path", None) if url else None
|
|
265
|
+
request_id = _extract_request_id(request)
|
|
266
|
+
|
|
267
|
+
context_parts = ["Handled exception"]
|
|
268
|
+
if method and path:
|
|
269
|
+
context_parts.append(f"{method} {path}")
|
|
270
|
+
if request_id:
|
|
271
|
+
context_parts.append(f"request_id={request_id}")
|
|
272
|
+
|
|
273
|
+
if isinstance(exc, ApiError) and exc.log_message:
|
|
274
|
+
context_parts.append(exc.log_message)
|
|
275
|
+
|
|
276
|
+
self.logger.error(" | ".join(context_parts), exc_info=exc)
|
|
277
|
+
|
|
278
|
+
def build_payload(self, exc: Exception, request: Any = None) -> dict[str, Any]:
|
|
279
|
+
info = self.describe(exc)
|
|
280
|
+
payload: dict[str, Any] = {
|
|
281
|
+
"success": False,
|
|
282
|
+
"response_code": info.status_code,
|
|
283
|
+
"status_code": info.status_code,
|
|
284
|
+
"error_message": info.message,
|
|
285
|
+
"error_type": exc.__class__.__name__,
|
|
286
|
+
"error": {
|
|
287
|
+
"code": info.code,
|
|
288
|
+
"type": exc.__class__.__name__,
|
|
289
|
+
"message": info.message,
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if info.details:
|
|
294
|
+
payload["error"]["details"] = info.details
|
|
295
|
+
payload["errors"] = info.details
|
|
296
|
+
|
|
297
|
+
request_id = _extract_request_id(request)
|
|
298
|
+
if request_id:
|
|
299
|
+
payload["request_id"] = request_id
|
|
300
|
+
|
|
301
|
+
url = _extract_request_value(request, "url")
|
|
302
|
+
if url and getattr(url, "path", None):
|
|
303
|
+
payload["path"] = url.path
|
|
304
|
+
|
|
305
|
+
return payload
|
|
306
|
+
|
|
307
|
+
def explain_error(self, exc: Exception) -> str:
|
|
308
|
+
return self.describe(exc).message
|
|
309
|
+
|
|
310
|
+
def get_status_code(self, exc: Exception) -> int:
|
|
311
|
+
return self.describe(exc).status_code
|
|
312
|
+
|
|
313
|
+
def handle_exception(self, exc: Exception, request: Any = None) -> Any:
|
|
314
|
+
payload = self.build_payload(exc, request=request)
|
|
315
|
+
if JSONResponse is None:
|
|
316
|
+
return payload
|
|
317
|
+
return JSONResponse(status_code=payload["status_code"], content=payload)
|
|
318
|
+
|
|
319
|
+
async def unified_exception_handler(self, request: Any, exc: Exception) -> Any:
|
|
320
|
+
self.log_exception(exc, request=request)
|
|
321
|
+
return self.handle_exception(exc, request=request)
|
|
322
|
+
|
|
323
|
+
def raise_http_exception(self, exc: Exception) -> None:
|
|
324
|
+
if FastAPIHTTPException is None:
|
|
325
|
+
raise RuntimeError("FastAPI must be installed to raise HTTPException objects.")
|
|
326
|
+
|
|
327
|
+
self.log_exception(exc)
|
|
328
|
+
info = self.describe(exc)
|
|
329
|
+
raise FastAPIHTTPException(status_code=info.status_code, detail=info.message)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
_default_handler = ErrorHandler()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def explain_error(exc: Exception) -> str:
|
|
336
|
+
return _default_handler.explain_error(exc)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def get_status_code(exc: Exception) -> int:
|
|
340
|
+
return _default_handler.get_status_code(exc)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def handle_exception(exc: Exception, request: Any = None) -> Any:
|
|
344
|
+
return _default_handler.handle_exception(exc, request=request)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def unified_exception_handler(request: Any, exc: Exception) -> Any:
|
|
348
|
+
return await _default_handler.unified_exception_handler(request, exc)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def ExceptionHandler(exc: Exception) -> None:
|
|
352
|
+
_default_handler.raise_http_exception(exc)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def register_exception_handlers(app: Any, handler: ErrorHandler | None = None) -> Any:
|
|
356
|
+
active_handler = handler or _default_handler
|
|
357
|
+
registered_types: list[type[Any]] = [Exception]
|
|
358
|
+
|
|
359
|
+
if FastAPIHTTPException and FastAPIHTTPException not in registered_types:
|
|
360
|
+
registered_types.append(FastAPIHTTPException)
|
|
361
|
+
if StarletteHTTPException and StarletteHTTPException not in registered_types:
|
|
362
|
+
registered_types.append(StarletteHTTPException)
|
|
363
|
+
if RequestValidationError and RequestValidationError not in registered_types:
|
|
364
|
+
registered_types.append(RequestValidationError)
|
|
365
|
+
|
|
366
|
+
for exception_type in registered_types:
|
|
367
|
+
app.add_exception_handler(exception_type, active_handler.unified_exception_handler)
|
|
368
|
+
|
|
369
|
+
return app
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DEFAULT_LOGGER_NAME = "global_api_tools"
|
|
7
|
+
LOG_FORMAT = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
|
|
8
|
+
_FILE_HANDLERS: dict[str, logging.FileHandler] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ensure_stream_handler(logger: logging.Logger) -> None:
|
|
12
|
+
for handler in logger.handlers:
|
|
13
|
+
if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler):
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
stream_handler = logging.StreamHandler()
|
|
17
|
+
stream_handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
|
18
|
+
logger.addHandler(stream_handler)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_log_path(file_name: str | Path) -> Path:
|
|
22
|
+
path = Path(file_name)
|
|
23
|
+
if not path.suffix:
|
|
24
|
+
path = path.with_suffix(".log")
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_logger(
|
|
30
|
+
name: str = DEFAULT_LOGGER_NAME,
|
|
31
|
+
*,
|
|
32
|
+
file_name: str | Path | None = None,
|
|
33
|
+
) -> logging.Logger:
|
|
34
|
+
logger = logging.getLogger(name)
|
|
35
|
+
logger.setLevel(logging.INFO)
|
|
36
|
+
_ensure_stream_handler(logger)
|
|
37
|
+
|
|
38
|
+
if file_name:
|
|
39
|
+
path = _resolve_log_path(file_name)
|
|
40
|
+
handler_key = str(path.resolve())
|
|
41
|
+
file_handler = _FILE_HANDLERS.get(handler_key)
|
|
42
|
+
|
|
43
|
+
if file_handler is None:
|
|
44
|
+
file_handler = logging.FileHandler(path)
|
|
45
|
+
file_handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
|
46
|
+
_FILE_HANDLERS[handler_key] = file_handler
|
|
47
|
+
|
|
48
|
+
if file_handler not in logger.handlers:
|
|
49
|
+
logger.addHandler(file_handler)
|
|
50
|
+
|
|
51
|
+
return logger
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def logs(
|
|
55
|
+
msg: object = "",
|
|
56
|
+
type: str = "info",
|
|
57
|
+
file_name: str | Path | None = None,
|
|
58
|
+
logger: logging.Logger | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
target_logger = logger or get_logger(file_name=file_name)
|
|
61
|
+
level_name = str(type).upper()
|
|
62
|
+
level = getattr(logging, level_name, logging.INFO)
|
|
63
|
+
target_logger.log(level, msg)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime, timedelta
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from typing import Any, Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
from ._compat import optional_import
|
|
9
|
+
from .logging import logs
|
|
10
|
+
|
|
11
|
+
JSONResponse = optional_import("fastapi.responses", "JSONResponse")
|
|
12
|
+
Response = optional_import("fastapi", "Response")
|
|
13
|
+
PydanticValidationError = optional_import("pydantic", "ValidationError")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def value_correction(data: Any) -> Any:
|
|
17
|
+
if isinstance(data, str):
|
|
18
|
+
return data.strip()
|
|
19
|
+
if isinstance(data, Decimal):
|
|
20
|
+
return float(data)
|
|
21
|
+
if isinstance(data, datetime):
|
|
22
|
+
return data.isoformat(sep=" ", timespec="seconds")
|
|
23
|
+
if isinstance(data, date):
|
|
24
|
+
return data.isoformat()
|
|
25
|
+
if isinstance(data, timedelta):
|
|
26
|
+
return str(data)
|
|
27
|
+
if isinstance(data, float):
|
|
28
|
+
return round(data, 2)
|
|
29
|
+
if isinstance(data, dict):
|
|
30
|
+
return {key: value_correction(value) for key, value in data.items()}
|
|
31
|
+
if isinstance(data, list):
|
|
32
|
+
return [value_correction(item) for item in data]
|
|
33
|
+
if isinstance(data, tuple):
|
|
34
|
+
return tuple(value_correction(item) for item in data)
|
|
35
|
+
if isinstance(data, set):
|
|
36
|
+
return [value_correction(item) for item in sorted(data, key=repr)]
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _http_status_message(response_code: int) -> str:
|
|
41
|
+
try:
|
|
42
|
+
phrase = HTTPStatus(response_code).phrase
|
|
43
|
+
except ValueError:
|
|
44
|
+
return "Request failed."
|
|
45
|
+
return phrase.replace("_", " ")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _dump_model(model: Any) -> Any:
|
|
49
|
+
if hasattr(model, "model_dump"):
|
|
50
|
+
return model.model_dump()
|
|
51
|
+
if hasattr(model, "dict"):
|
|
52
|
+
return model.dict()
|
|
53
|
+
return model
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_with_schema(schema: Any, item: Any) -> Any:
|
|
57
|
+
if hasattr(schema, "validate_python"):
|
|
58
|
+
return _dump_model(schema.validate_python(item))
|
|
59
|
+
if hasattr(schema, "model_validate"):
|
|
60
|
+
return _dump_model(schema.model_validate(item))
|
|
61
|
+
if hasattr(schema, "parse_obj"):
|
|
62
|
+
return _dump_model(schema.parse_obj(item))
|
|
63
|
+
raise TypeError("schema must be a Pydantic model class or adapter")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_validation_errors(exc: Exception) -> list[dict[str, str]]:
|
|
67
|
+
if not hasattr(exc, "errors"):
|
|
68
|
+
return [{"field": "data", "message": str(exc) or "Validation failed."}]
|
|
69
|
+
|
|
70
|
+
formatted_errors: list[dict[str, str]] = []
|
|
71
|
+
for error in exc.errors(): # type: ignore[attr-defined]
|
|
72
|
+
location = [str(part) for part in error.get("loc", []) if part != "__root__"]
|
|
73
|
+
if location and location[0] in {"body", "query", "path"}:
|
|
74
|
+
location = location[1:]
|
|
75
|
+
field_name = ".".join(location) if location else "data"
|
|
76
|
+
formatted_errors.append(
|
|
77
|
+
{
|
|
78
|
+
"field": field_name,
|
|
79
|
+
"message": error.get("msg") or error.get("message") or "Invalid value.",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return formatted_errors
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _serialize_data(data: Any, schema: Any | None) -> Any:
|
|
86
|
+
if schema is None:
|
|
87
|
+
return value_correction(data)
|
|
88
|
+
if isinstance(data, list):
|
|
89
|
+
return [value_correction(_validate_with_schema(schema, item)) for item in data]
|
|
90
|
+
return value_correction(_validate_with_schema(schema, data))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _set_status_aliases(payload: dict[str, Any], response_code: int) -> None:
|
|
94
|
+
payload["response_code"] = response_code
|
|
95
|
+
payload["status_code"] = response_code
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _set_error_aliases(
|
|
99
|
+
payload: dict[str, Any],
|
|
100
|
+
*,
|
|
101
|
+
message: str,
|
|
102
|
+
details: Sequence[Mapping[str, Any]] | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
payload["error_message"] = message
|
|
105
|
+
if details:
|
|
106
|
+
payload["errors"] = [dict(item) for item in details]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def create_response(
|
|
110
|
+
response_code: int,
|
|
111
|
+
data: Any = None,
|
|
112
|
+
schema: Any | None = None,
|
|
113
|
+
pagination: Mapping[str, Any] | None = None,
|
|
114
|
+
error_message: str | None = None,
|
|
115
|
+
error_code: str | None = None,
|
|
116
|
+
details: Sequence[Mapping[str, Any]] | None = None,
|
|
117
|
+
*,
|
|
118
|
+
as_json_response: bool = True,
|
|
119
|
+
) -> Any:
|
|
120
|
+
if response_code == 204 and as_json_response:
|
|
121
|
+
if Response is None:
|
|
122
|
+
raise RuntimeError("FastAPI must be installed to return a Response object.")
|
|
123
|
+
return Response(status_code=204)
|
|
124
|
+
|
|
125
|
+
payload: dict[str, Any] = {
|
|
126
|
+
"success": 200 <= response_code < 300,
|
|
127
|
+
}
|
|
128
|
+
_set_status_aliases(payload, response_code)
|
|
129
|
+
|
|
130
|
+
if data is not None:
|
|
131
|
+
try:
|
|
132
|
+
payload["data"] = _serialize_data(data, schema)
|
|
133
|
+
except Exception as exc: # noqa: BLE001
|
|
134
|
+
if PydanticValidationError and isinstance(exc, PydanticValidationError):
|
|
135
|
+
validation_errors = _format_validation_errors(exc)
|
|
136
|
+
else:
|
|
137
|
+
validation_errors = [{"field": "data", "message": str(exc) or "Validation failed."}]
|
|
138
|
+
|
|
139
|
+
logs(validation_errors, type="error")
|
|
140
|
+
response_code = 422
|
|
141
|
+
payload["success"] = False
|
|
142
|
+
_set_status_aliases(payload, response_code)
|
|
143
|
+
payload["error"] = {
|
|
144
|
+
"code": "validation_error",
|
|
145
|
+
"message": "Response data did not match the expected schema.",
|
|
146
|
+
"details": validation_errors,
|
|
147
|
+
}
|
|
148
|
+
_set_error_aliases(
|
|
149
|
+
payload,
|
|
150
|
+
message="Response data did not match the expected schema.",
|
|
151
|
+
details=validation_errors,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if pagination is not None:
|
|
155
|
+
payload["pagination"] = value_correction(dict(pagination))
|
|
156
|
+
|
|
157
|
+
if "error" not in payload and (error_message or response_code >= 400):
|
|
158
|
+
payload["success"] = False
|
|
159
|
+
_set_status_aliases(payload, response_code)
|
|
160
|
+
error_obj: dict[str, Any] = {
|
|
161
|
+
"code": error_code or "api_error",
|
|
162
|
+
"message": error_message or _http_status_message(response_code),
|
|
163
|
+
}
|
|
164
|
+
if details:
|
|
165
|
+
error_obj["details"] = [dict(item) for item in details]
|
|
166
|
+
payload["error"] = error_obj
|
|
167
|
+
_set_error_aliases(
|
|
168
|
+
payload,
|
|
169
|
+
message=error_obj["message"],
|
|
170
|
+
details=error_obj.get("details"),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if not as_json_response:
|
|
174
|
+
return payload
|
|
175
|
+
|
|
176
|
+
if JSONResponse is None:
|
|
177
|
+
raise RuntimeError("FastAPI must be installed to return a JSONResponse object.")
|
|
178
|
+
return JSONResponse(content=payload, status_code=response_code)
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: global-api-tools
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Reusable API response and exception handling utilities with FastAPI integration.
|
|
5
|
+
Author: Aniket Modi
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/aniketmodi123/reusable_code_lib
|
|
8
|
+
Project-URL: Repository, https://github.com/aniketmodi123/reusable_code_lib
|
|
9
|
+
Keywords: api,fastapi,error-handling,responses,utilities
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: fastapi
|
|
21
|
+
Requires-Dist: fastapi>=0.110; extra == "fastapi"
|
|
22
|
+
Provides-Extra: pydantic
|
|
23
|
+
Requires-Dist: pydantic>=1.10; extra == "pydantic"
|
|
24
|
+
Provides-Extra: database
|
|
25
|
+
Requires-Dist: sqlalchemy>=1.4; extra == "database"
|
|
26
|
+
Requires-Dist: asyncpg>=0.29; extra == "database"
|
|
27
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "database"
|
|
28
|
+
Provides-Extra: full
|
|
29
|
+
Requires-Dist: fastapi>=0.110; extra == "full"
|
|
30
|
+
Requires-Dist: pydantic>=1.10; extra == "full"
|
|
31
|
+
Requires-Dist: sqlalchemy>=1.4; extra == "full"
|
|
32
|
+
Requires-Dist: asyncpg>=0.29; extra == "full"
|
|
33
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "full"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# global-api-tools
|
|
37
|
+
|
|
38
|
+
`global-api-tools` is a reusable Python library for centralized API responses, logging, and exception handling.
|
|
39
|
+
|
|
40
|
+
It gives you one place to manage:
|
|
41
|
+
|
|
42
|
+
- `create_response`
|
|
43
|
+
- `value_correction`
|
|
44
|
+
- `logs`
|
|
45
|
+
- `ExceptionHandler`
|
|
46
|
+
- `unified_exception_handler`
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install global-api-tools
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For local development:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Optional extras:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install ".[fastapi]"
|
|
64
|
+
pip install ".[pydantic]"
|
|
65
|
+
pip install ".[database]"
|
|
66
|
+
pip install ".[full]"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quick usage
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from fastapi import FastAPI
|
|
73
|
+
from global_api_tools import create_response, register_exception_handlers
|
|
74
|
+
|
|
75
|
+
app = FastAPI()
|
|
76
|
+
register_exception_handlers(app)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.get("/health")
|
|
80
|
+
async def health():
|
|
81
|
+
return create_response(200, data={"status": "ok"})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Public API
|
|
85
|
+
|
|
86
|
+
Import from the published package like this:
|
|
87
|
+
|
|
88
|
+
Use only this import style in other projects:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from global_api_tools import (
|
|
92
|
+
ApiError,
|
|
93
|
+
ErrorHandler,
|
|
94
|
+
ExceptionHandler,
|
|
95
|
+
create_response,
|
|
96
|
+
explain_error,
|
|
97
|
+
get_logger,
|
|
98
|
+
get_status_code,
|
|
99
|
+
handle_exception,
|
|
100
|
+
logs,
|
|
101
|
+
register_exception_handlers,
|
|
102
|
+
unified_exception_handler,
|
|
103
|
+
value_correction,
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `create_response`
|
|
108
|
+
|
|
109
|
+
Params:
|
|
110
|
+
|
|
111
|
+
- `response_code: int`
|
|
112
|
+
- `data: Any = None`
|
|
113
|
+
- `schema: Any | None = None`
|
|
114
|
+
- `pagination: Mapping[str, Any] | None = None`
|
|
115
|
+
- `error_message: str | None = None`
|
|
116
|
+
- `error_code: str | None = None`
|
|
117
|
+
- `details: Sequence[Mapping[str, Any]] | None = None`
|
|
118
|
+
- `as_json_response: bool = True`
|
|
119
|
+
|
|
120
|
+
Usage:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from global_api_tools import create_response
|
|
124
|
+
|
|
125
|
+
return create_response(
|
|
126
|
+
200,
|
|
127
|
+
data={"name": "Aniket"},
|
|
128
|
+
pagination={"page": 1, "rows": 10, "total_rows": 100},
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `value_correction`
|
|
133
|
+
|
|
134
|
+
Params:
|
|
135
|
+
|
|
136
|
+
- `data: Any`
|
|
137
|
+
|
|
138
|
+
Usage:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from decimal import Decimal
|
|
142
|
+
from global_api_tools import value_correction
|
|
143
|
+
|
|
144
|
+
cleaned = value_correction({"amount": Decimal("10.50"), "name": " demo "})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `logs`
|
|
148
|
+
|
|
149
|
+
Params:
|
|
150
|
+
|
|
151
|
+
- `msg: object = ""`
|
|
152
|
+
- `type: str = "info"`
|
|
153
|
+
- `file_name: str | Path | None = None`
|
|
154
|
+
- `logger: logging.Logger | None = None`
|
|
155
|
+
|
|
156
|
+
Usage:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from global_api_tools import logs
|
|
160
|
+
|
|
161
|
+
logs("report created", type="info")
|
|
162
|
+
logs("database failed", type="error", file_name="logs/app")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### `get_logger`
|
|
166
|
+
|
|
167
|
+
Params:
|
|
168
|
+
|
|
169
|
+
- `name: str = "global_api_tools"`
|
|
170
|
+
- `file_name: str | Path | None = None`
|
|
171
|
+
|
|
172
|
+
Usage:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from global_api_tools import get_logger
|
|
176
|
+
|
|
177
|
+
logger = get_logger("my_app", file_name="logs/app.log")
|
|
178
|
+
logger.info("started")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `unified_exception_handler`
|
|
182
|
+
|
|
183
|
+
Params:
|
|
184
|
+
|
|
185
|
+
- `request`
|
|
186
|
+
- `exc: Exception`
|
|
187
|
+
|
|
188
|
+
Usage:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from fastapi import FastAPI, HTTPException
|
|
192
|
+
from fastapi.exceptions import RequestValidationError
|
|
193
|
+
from global_api_tools import unified_exception_handler
|
|
194
|
+
|
|
195
|
+
app = FastAPI()
|
|
196
|
+
|
|
197
|
+
app.add_exception_handler(HTTPException, unified_exception_handler)
|
|
198
|
+
app.add_exception_handler(Exception, unified_exception_handler)
|
|
199
|
+
app.add_exception_handler(RequestValidationError, unified_exception_handler)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### `register_exception_handlers`
|
|
203
|
+
|
|
204
|
+
Params:
|
|
205
|
+
|
|
206
|
+
- `app`
|
|
207
|
+
- `handler: ErrorHandler | None = None`
|
|
208
|
+
|
|
209
|
+
Usage:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from fastapi import FastAPI
|
|
213
|
+
from global_api_tools import register_exception_handlers
|
|
214
|
+
|
|
215
|
+
app = FastAPI()
|
|
216
|
+
register_exception_handlers(app)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### `ExceptionHandler`
|
|
220
|
+
|
|
221
|
+
Params:
|
|
222
|
+
|
|
223
|
+
- `exc: Exception`
|
|
224
|
+
|
|
225
|
+
Usage:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from global_api_tools import ExceptionHandler
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
raise ValueError("invalid meter id")
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
ExceptionHandler(exc)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### `handle_exception`
|
|
237
|
+
|
|
238
|
+
Params:
|
|
239
|
+
|
|
240
|
+
- `exc: Exception`
|
|
241
|
+
- `request = None`
|
|
242
|
+
|
|
243
|
+
Usage:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from global_api_tools import handle_exception
|
|
247
|
+
|
|
248
|
+
payload_or_response = handle_exception(ValueError("invalid input"))
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### `explain_error`
|
|
252
|
+
|
|
253
|
+
Params:
|
|
254
|
+
|
|
255
|
+
- `exc: Exception`
|
|
256
|
+
|
|
257
|
+
Usage:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
from global_api_tools import explain_error
|
|
261
|
+
|
|
262
|
+
message = explain_error(ValueError("invalid input"))
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### `get_status_code`
|
|
266
|
+
|
|
267
|
+
Params:
|
|
268
|
+
|
|
269
|
+
- `exc: Exception`
|
|
270
|
+
|
|
271
|
+
Usage:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from global_api_tools import get_status_code
|
|
275
|
+
|
|
276
|
+
status_code = get_status_code(ValueError("invalid input"))
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### `ErrorHandler`
|
|
280
|
+
|
|
281
|
+
Params:
|
|
282
|
+
|
|
283
|
+
- `logger_name: str = "global_api_tools.errors"`
|
|
284
|
+
|
|
285
|
+
Usage:
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
from global_api_tools import ErrorHandler
|
|
289
|
+
|
|
290
|
+
handler = ErrorHandler()
|
|
291
|
+
payload = handler.build_payload(ValueError("invalid input"))
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### `ApiError`
|
|
295
|
+
|
|
296
|
+
Params:
|
|
297
|
+
|
|
298
|
+
- `message: str`
|
|
299
|
+
- `status_code: int = 400`
|
|
300
|
+
- `code: str = "api_error"`
|
|
301
|
+
- `details: list[dict[str, Any]] | None = None`
|
|
302
|
+
- `log_message: str | None = None`
|
|
303
|
+
|
|
304
|
+
Usage:
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from global_api_tools import ApiError
|
|
308
|
+
|
|
309
|
+
raise ApiError(
|
|
310
|
+
"Report is not ready.",
|
|
311
|
+
status_code=409,
|
|
312
|
+
code="report_pending",
|
|
313
|
+
details=[{"field": "report_id", "message": "still processing"}],
|
|
314
|
+
)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Error response format
|
|
318
|
+
|
|
319
|
+
Every API failure uses one consistent structure:
|
|
320
|
+
|
|
321
|
+
```json
|
|
322
|
+
{
|
|
323
|
+
"success": false,
|
|
324
|
+
"status_code": 422,
|
|
325
|
+
"error": {
|
|
326
|
+
"code": "validation_error",
|
|
327
|
+
"type": "RequestValidationError",
|
|
328
|
+
"message": "One or more fields are invalid.",
|
|
329
|
+
"details": [
|
|
330
|
+
{
|
|
331
|
+
"field": "email",
|
|
332
|
+
"message": "field required"
|
|
333
|
+
}
|
|
334
|
+
]
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## Package structure
|
|
340
|
+
|
|
341
|
+
```text
|
|
342
|
+
src/global_api_tools/
|
|
343
|
+
__init__.py
|
|
344
|
+
_compat.py
|
|
345
|
+
exceptions.py
|
|
346
|
+
logging.py
|
|
347
|
+
responses.py
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Extending it
|
|
351
|
+
|
|
352
|
+
Add new shared functionality inside `src/global_api_tools/` and re-export it from `src/global_api_tools/__init__.py`. That keeps imports stable across every project that uses the package.
|
|
353
|
+
|
|
354
|
+
## Import Rule
|
|
355
|
+
|
|
356
|
+
For installed usage, import from `global_api_tools` only.
|
|
357
|
+
|
|
358
|
+
Correct:
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
from global_api_tools import create_response, unified_exception_handler
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Do not rely on local-only paths like `utils` or `exception_handler` in other projects.
|
|
365
|
+
|
|
366
|
+
## Auto Publish From `prod`
|
|
367
|
+
|
|
368
|
+
This repo is configured so that a merged pull request into the `prod` branch publishes the package to PyPI through GitHub Actions.
|
|
369
|
+
|
|
370
|
+
Required setup:
|
|
371
|
+
|
|
372
|
+
1. Create the package on PyPI if needed.
|
|
373
|
+
2. In PyPI, configure a Trusted Publisher with these values:
|
|
374
|
+
- Project name: `global-api-tools`
|
|
375
|
+
- Owner: `aniketmodi123`
|
|
376
|
+
- Repository: `reusable_code_lib`
|
|
377
|
+
- Workflow: `publish-pypi.yml`
|
|
378
|
+
3. Before merging into `prod`, increase the version in `pyproject.toml`. PyPI will reject duplicate versions.
|
|
379
|
+
|
|
380
|
+
Workflow file:
|
|
381
|
+
|
|
382
|
+
- `.github/workflows/publish-pypi.yml`
|
|
383
|
+
|
|
384
|
+
Release flow:
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
git checkout dev
|
|
388
|
+
# make code changes
|
|
389
|
+
# update version in pyproject.toml when this is a release
|
|
390
|
+
git add .
|
|
391
|
+
git commit -m "release: 0.1.1"
|
|
392
|
+
git push origin dev
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Then:
|
|
396
|
+
|
|
397
|
+
1. Create a pull request from `dev` to `prod`
|
|
398
|
+
2. Review and approve it
|
|
399
|
+
3. Merge the pull request
|
|
400
|
+
|
|
401
|
+
When the PR is merged into `prod`, GitHub Actions will use Trusted Publishing to:
|
|
402
|
+
|
|
403
|
+
- build the wheel and source distribution
|
|
404
|
+
- validate the package
|
|
405
|
+
- upload it to PyPI
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
global_api_tools/__init__.py,sha256=vEErhowuXm-Ibdnxv31mrO0Dl11YrJAH0YMWE8UC5aE,591
|
|
2
|
+
global_api_tools/_compat.py,sha256=_9C9S_jG4g8aePVgUNLJjrjZGDV0LSEdZ8Mit1EYnuo,492
|
|
3
|
+
global_api_tools/exceptions.py,sha256=LvFMfeTd7nH_h-IYitiHR1qJE0jQQGP1Gp_jKIMiyqA,15007
|
|
4
|
+
global_api_tools/logging.py,sha256=_am3kocQ83dvHbsNYAWN-3uBu0i1RDZSjKv74b1eCtI,1871
|
|
5
|
+
global_api_tools/responses.py,sha256=QaiWHdQm34GJclX1pJTTJXO4pZKnmKC48HLaR3VN68I,6232
|
|
6
|
+
global_api_tools-0.1.1.dist-info/licenses/LICENSE,sha256=fHBOcmG4wnlk3QUq24bbuHainPKxT1niUGyelQyYaWA,1068
|
|
7
|
+
global_api_tools-0.1.1.dist-info/METADATA,sha256=_POXpOdYZk6hwTLa0d49P0GzB82hsDJE3gBBVlACjws,8056
|
|
8
|
+
global_api_tools-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
global_api_tools-0.1.1.dist-info/top_level.txt,sha256=cA_IgXwOZPUVkI5a4gaV54icshc_JCW1UJqdz7TaXLI,17
|
|
10
|
+
global_api_tools-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aniket Modi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
global_api_tools
|