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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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