tempest-fastapi-sdk 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/PKG-INFO +1 -1
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/pyproject.toml +1 -1
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/__init__.py +25 -1
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/api/__init__.py +8 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/api/dependencies/__init__.py +11 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/api/dependencies/auth.py +84 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/api/middlewares/__init__.py +7 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/api/middlewares/request_id.py +66 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/controllers/__init__.py +7 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/controllers/base.py +126 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/core/__init__.py +21 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/core/context.py +55 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/core/logging.py +130 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/repository.py +38 -6
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/services/__init__.py +7 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/services/base.py +165 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/testing/__init__.py +26 -0
- tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/testing/database.py +158 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/upload.py +14 -3
- tempest_fastapi_sdk-0.3.0/tests/api/test_dependencies_auth.py +77 -0
- tempest_fastapi_sdk-0.3.0/tests/api/test_request_id_middleware.py +73 -0
- tempest_fastapi_sdk-0.3.0/tests/controllers/test_base.py +88 -0
- tempest_fastapi_sdk-0.3.0/tests/core/test_context.py +36 -0
- tempest_fastapi_sdk-0.3.0/tests/core/test_logging.py +112 -0
- tempest_fastapi_sdk-0.3.0/tests/services/__init__.py +0 -0
- tempest_fastapi_sdk-0.3.0/tests/services/test_base.py +105 -0
- tempest_fastapi_sdk-0.3.0/tests/settings/__init__.py +0 -0
- tempest_fastapi_sdk-0.3.0/tests/testing/__init__.py +0 -0
- tempest_fastapi_sdk-0.3.0/tests/testing/test_database.py +57 -0
- tempest_fastapi_sdk-0.3.0/tests/utils/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/uv.lock +1 -1
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/.github/workflows/ci.yml +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/.github/workflows/release-pypi.yml +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/.gitignore +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/.python-version +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/Makefile +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/README.md +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/api/handlers.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/_alembic_templates/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/_alembic_templates/env.py.template +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/connection.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/migrations.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/model.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/base.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/conflict.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/forbidden.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/jwt.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/not_found.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/unauthorized.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/upload.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/validation.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/schemas/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/schemas/base.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/schemas/pagination.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/schemas/response.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/settings/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/settings/base.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/datetime.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/dict.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/email.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/jwt.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/password.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/regex.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/api/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/api/test_handlers.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/conftest.py +0 -0
- {tempest_fastapi_sdk-0.2.0/tests/db → tempest_fastapi_sdk-0.3.0/tests/controllers}/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0/tests/exceptions → tempest_fastapi_sdk-0.3.0/tests/core}/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0/tests/schemas → tempest_fastapi_sdk-0.3.0/tests/db}/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/db/test_connection.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/db/test_migrations.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/db/test_model.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/db/test_repository.py +0 -0
- {tempest_fastapi_sdk-0.2.0/tests/settings → tempest_fastapi_sdk-0.3.0/tests/exceptions}/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/exceptions/test_exceptions.py +0 -0
- {tempest_fastapi_sdk-0.2.0/tests/utils → tempest_fastapi_sdk-0.3.0/tests/schemas}/__init__.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/schemas/test_base.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/schemas/test_pagination.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/schemas/test_response.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/settings/test_base.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_datetime.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_dict.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_email.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_jwt.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_password.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_regex.py +0 -0
- {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_upload.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tempest-fastapi-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Shared FastAPI building blocks: base schemas, ORM model, async repository, exceptions, pagination and settings — the conventions used across Tempest projects.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mauriciobenjamin700/tempest-fastapi-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/mauriciobenjamin700/tempest-fastapi-sdk
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tempest-fastapi-sdk"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Shared FastAPI building blocks: base schemas, ORM model, async repository, exceptions, pagination and settings — the conventions used across Tempest projects."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
"""tempest-fastapi-sdk — shared FastAPI/SQLAlchemy/Pydantic primitives."""
|
|
2
2
|
|
|
3
3
|
from tempest_fastapi_sdk.api import (
|
|
4
|
+
RequestIDMiddleware,
|
|
4
5
|
app_exception_handler,
|
|
6
|
+
make_token_dependency,
|
|
5
7
|
register_exception_handlers,
|
|
8
|
+
require_x_token,
|
|
9
|
+
)
|
|
10
|
+
from tempest_fastapi_sdk.controllers import BaseController
|
|
11
|
+
from tempest_fastapi_sdk.core import (
|
|
12
|
+
JSONFormatter,
|
|
13
|
+
clear_request_id,
|
|
14
|
+
configure_logging,
|
|
15
|
+
get_request_id,
|
|
16
|
+
request_id_ctx,
|
|
17
|
+
set_request_id,
|
|
6
18
|
)
|
|
7
19
|
from tempest_fastapi_sdk.db import (
|
|
8
20
|
NAMING_CONVENTION,
|
|
@@ -29,6 +41,7 @@ from tempest_fastapi_sdk.schemas import (
|
|
|
29
41
|
BaseResponseSchema,
|
|
30
42
|
BaseSchema,
|
|
31
43
|
)
|
|
44
|
+
from tempest_fastapi_sdk.services import BaseService
|
|
32
45
|
from tempest_fastapi_sdk.settings import BaseAppSettings
|
|
33
46
|
from tempest_fastapi_sdk.utils import (
|
|
34
47
|
CNPJ,
|
|
@@ -57,7 +70,7 @@ from tempest_fastapi_sdk.utils import (
|
|
|
57
70
|
utcnow,
|
|
58
71
|
)
|
|
59
72
|
|
|
60
|
-
__version__: str = "0.
|
|
73
|
+
__version__: str = "0.3.0"
|
|
61
74
|
|
|
62
75
|
__all__: list[str] = [
|
|
63
76
|
"CNPJ",
|
|
@@ -71,12 +84,14 @@ __all__: list[str] = [
|
|
|
71
84
|
"AppException",
|
|
72
85
|
"AsyncDatabaseManager",
|
|
73
86
|
"BaseAppSettings",
|
|
87
|
+
"BaseController",
|
|
74
88
|
"BaseModel",
|
|
75
89
|
"BasePaginationFilterSchema",
|
|
76
90
|
"BasePaginationSchema",
|
|
77
91
|
"BaseRepository",
|
|
78
92
|
"BaseResponseSchema",
|
|
79
93
|
"BaseSchema",
|
|
94
|
+
"BaseService",
|
|
80
95
|
"CPFOrCNPJ",
|
|
81
96
|
"ConflictException",
|
|
82
97
|
"EmailUtils",
|
|
@@ -85,19 +100,25 @@ __all__: list[str] = [
|
|
|
85
100
|
"ForbiddenException",
|
|
86
101
|
"InvalidFileTypeException",
|
|
87
102
|
"InvalidTokenException",
|
|
103
|
+
"JSONFormatter",
|
|
88
104
|
"JWTUtils",
|
|
89
105
|
"NotFoundException",
|
|
90
106
|
"PasswordUtils",
|
|
91
107
|
"PhoneBR",
|
|
108
|
+
"RequestIDMiddleware",
|
|
92
109
|
"UnauthorizedException",
|
|
93
110
|
"UploadUtils",
|
|
94
111
|
"ValidationException",
|
|
95
112
|
"__version__",
|
|
96
113
|
"app_exception_handler",
|
|
114
|
+
"clear_request_id",
|
|
115
|
+
"configure_logging",
|
|
116
|
+
"get_request_id",
|
|
97
117
|
"is_valid_cnpj",
|
|
98
118
|
"is_valid_cpf",
|
|
99
119
|
"is_valid_cpf_cnpj",
|
|
100
120
|
"is_valid_phone_br",
|
|
121
|
+
"make_token_dependency",
|
|
101
122
|
"modify_dict",
|
|
102
123
|
"normalize_cnpj",
|
|
103
124
|
"normalize_cpf",
|
|
@@ -105,6 +126,9 @@ __all__: list[str] = [
|
|
|
105
126
|
"normalize_phone_br",
|
|
106
127
|
"only_digits",
|
|
107
128
|
"register_exception_handlers",
|
|
129
|
+
"request_id_ctx",
|
|
130
|
+
"require_x_token",
|
|
131
|
+
"set_request_id",
|
|
108
132
|
"to_utc",
|
|
109
133
|
"utcnow",
|
|
110
134
|
]
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
"""FastAPI integration primitives exposed at module level."""
|
|
2
2
|
|
|
3
|
+
from tempest_fastapi_sdk.api.dependencies import (
|
|
4
|
+
make_token_dependency,
|
|
5
|
+
require_x_token,
|
|
6
|
+
)
|
|
3
7
|
from tempest_fastapi_sdk.api.handlers import (
|
|
4
8
|
app_exception_handler,
|
|
5
9
|
register_exception_handlers,
|
|
6
10
|
)
|
|
11
|
+
from tempest_fastapi_sdk.api.middlewares import RequestIDMiddleware
|
|
7
12
|
|
|
8
13
|
__all__: list[str] = [
|
|
14
|
+
"RequestIDMiddleware",
|
|
9
15
|
"app_exception_handler",
|
|
16
|
+
"make_token_dependency",
|
|
10
17
|
"register_exception_handlers",
|
|
18
|
+
"require_x_token",
|
|
11
19
|
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Authentication dependencies (shared-secret token validation)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
from collections.abc import Callable, Coroutine
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import Header
|
|
10
|
+
|
|
11
|
+
from tempest_fastapi_sdk.exceptions.unauthorized import UnauthorizedException
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_token_dependency(
|
|
15
|
+
secret: str,
|
|
16
|
+
*,
|
|
17
|
+
header_name: str = "X-Token",
|
|
18
|
+
error_message: str = "Invalid or missing token",
|
|
19
|
+
) -> Callable[..., Coroutine[Any, Any, None]]:
|
|
20
|
+
"""Build a FastAPI dependency that validates a shared-secret header.
|
|
21
|
+
|
|
22
|
+
The returned coroutine compares the inbound header value with
|
|
23
|
+
``secret`` using :func:`hmac.compare_digest` (constant-time). An
|
|
24
|
+
empty ``secret`` disables the check entirely — intentional for
|
|
25
|
+
local development; production deployments should always provide
|
|
26
|
+
a non-empty value.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
secret (str): The shared secret to compare against. Empty
|
|
30
|
+
string disables enforcement.
|
|
31
|
+
header_name (str): The header to read. Defaults to
|
|
32
|
+
``"X-Token"``.
|
|
33
|
+
error_message (str): Message attached to the raised
|
|
34
|
+
:class:`UnauthorizedException`.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Callable[..., Coroutine[Any, Any, None]]: An async FastAPI
|
|
38
|
+
dependency that raises :class:`UnauthorizedException` on
|
|
39
|
+
mismatch and returns ``None`` on success.
|
|
40
|
+
"""
|
|
41
|
+
alias = header_name
|
|
42
|
+
|
|
43
|
+
async def _require_token(token: str = Header(default="", alias=alias)) -> None:
|
|
44
|
+
if not secret:
|
|
45
|
+
return
|
|
46
|
+
if not hmac.compare_digest(token, secret):
|
|
47
|
+
raise UnauthorizedException(message=error_message)
|
|
48
|
+
|
|
49
|
+
_require_token.__doc__ = (
|
|
50
|
+
f"Validate the {alias} header against the configured shared secret."
|
|
51
|
+
)
|
|
52
|
+
return _require_token
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def require_x_token(
|
|
56
|
+
secret: str,
|
|
57
|
+
token: str,
|
|
58
|
+
*,
|
|
59
|
+
error_message: str = "Invalid or missing token",
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Imperative variant of :func:`make_token_dependency`.
|
|
62
|
+
|
|
63
|
+
Useful when validation happens outside the FastAPI dependency
|
|
64
|
+
pipeline (e.g. from a websocket handler or background task).
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
secret (str): The shared secret.
|
|
68
|
+
token (str): The token to verify.
|
|
69
|
+
error_message (str): Message attached on failure.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
UnauthorizedException: When ``secret`` is non-empty and the
|
|
73
|
+
tokens do not match in constant time.
|
|
74
|
+
"""
|
|
75
|
+
if not secret:
|
|
76
|
+
return
|
|
77
|
+
if not hmac.compare_digest(token, secret):
|
|
78
|
+
raise UnauthorizedException(message=error_message)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__: list[str] = [
|
|
82
|
+
"make_token_dependency",
|
|
83
|
+
"require_x_token",
|
|
84
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Correlation-ID middleware bridging HTTP headers and log context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
from starlette.types import ASGIApp
|
|
12
|
+
|
|
13
|
+
from tempest_fastapi_sdk.core.context import clear_request_id, set_request_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
|
17
|
+
"""Bind an ``X-Request-ID`` header to the request-scoped context.
|
|
18
|
+
|
|
19
|
+
Reads the inbound header (or generates a fresh UUID v4 when
|
|
20
|
+
absent), stores it via :func:`set_request_id` so log records
|
|
21
|
+
written during the request carry the ``request_id`` field, and
|
|
22
|
+
echoes the same value back on the response so callers can trace
|
|
23
|
+
end-to-end across services.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
app (ASGIApp): The wrapped ASGI application.
|
|
27
|
+
header_name (str): The header to read/write. Defaults to
|
|
28
|
+
``"X-Request-ID"``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
app: ASGIApp,
|
|
34
|
+
header_name: str = "X-Request-ID",
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(app)
|
|
37
|
+
self.header_name: str = header_name
|
|
38
|
+
|
|
39
|
+
async def dispatch(
|
|
40
|
+
self,
|
|
41
|
+
request: Request,
|
|
42
|
+
call_next: Callable[[Request], Awaitable[Response]],
|
|
43
|
+
) -> Response:
|
|
44
|
+
"""Run the wrapped handler with a bound request ID.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
request (Request): The inbound request.
|
|
48
|
+
call_next: The downstream ASGI handler.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Response: The handler's response with the request ID
|
|
52
|
+
echoed in the configured header.
|
|
53
|
+
"""
|
|
54
|
+
rid = request.headers.get(self.header_name) or str(uuid.uuid4())
|
|
55
|
+
token = set_request_id(rid)
|
|
56
|
+
try:
|
|
57
|
+
response = await call_next(request)
|
|
58
|
+
finally:
|
|
59
|
+
clear_request_id(token)
|
|
60
|
+
response.headers[self.header_name] = rid
|
|
61
|
+
return response
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__: list[str] = [
|
|
65
|
+
"RequestIDMiddleware",
|
|
66
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Generic controller skeleton bridging routers and services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Generic, TypeVar, cast
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from tempest_fastapi_sdk.services.base import BaseService
|
|
9
|
+
|
|
10
|
+
ServiceT = TypeVar("ServiceT", bound=BaseService[Any, Any])
|
|
11
|
+
ResponseT = TypeVar("ResponseT")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseController(Generic[ServiceT, ResponseT]):
|
|
15
|
+
"""Thin orchestration layer between routers and services.
|
|
16
|
+
|
|
17
|
+
Following the SDK layering rules (router → controller → service →
|
|
18
|
+
repository), controllers are kept present even when no
|
|
19
|
+
orchestration is required so the import graph stays uniform.
|
|
20
|
+
Override methods here when a single endpoint needs to call
|
|
21
|
+
multiple services or apply cross-cutting policy; leave the
|
|
22
|
+
pass-throughs untouched otherwise.
|
|
23
|
+
|
|
24
|
+
Generic parameters:
|
|
25
|
+
ServiceT: The concrete service class.
|
|
26
|
+
ResponseT: The response schema returned to the router.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
service (ServiceT): The service the controller delegates to.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, service: ServiceT) -> None:
|
|
33
|
+
"""Initialize the controller.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
service (ServiceT): The service to delegate to.
|
|
37
|
+
"""
|
|
38
|
+
self.service: ServiceT = service
|
|
39
|
+
|
|
40
|
+
async def get_by_id(self, id: UUID) -> ResponseT:
|
|
41
|
+
"""Pass-through to :meth:`BaseService.get_by_id`.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
id (UUID): The primary key.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
ResponseT: The mapped response.
|
|
48
|
+
"""
|
|
49
|
+
return cast("ResponseT", await self.service.get_by_id(id))
|
|
50
|
+
|
|
51
|
+
async def list(
|
|
52
|
+
self,
|
|
53
|
+
filters: dict[str, Any] | None = None,
|
|
54
|
+
order_by: Any | None = None,
|
|
55
|
+
ascending: bool = True,
|
|
56
|
+
) -> list[ResponseT]:
|
|
57
|
+
"""Pass-through to :meth:`BaseService.list`.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
filters (dict[str, Any] | None): Filter conditions.
|
|
61
|
+
order_by: A SQLAlchemy column expression.
|
|
62
|
+
ascending (bool): Whether to order ascending.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
list[ResponseT]: The mapped responses.
|
|
66
|
+
"""
|
|
67
|
+
return cast(
|
|
68
|
+
"list[ResponseT]",
|
|
69
|
+
await self.service.list(
|
|
70
|
+
filters=filters,
|
|
71
|
+
order_by=order_by,
|
|
72
|
+
ascending=ascending,
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def paginate(
|
|
77
|
+
self,
|
|
78
|
+
filters: dict[str, Any] | None = None,
|
|
79
|
+
order_by: str | None = None,
|
|
80
|
+
page: int = 1,
|
|
81
|
+
page_size: int = 20,
|
|
82
|
+
ascending: bool = True,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""Pass-through to :meth:`BaseService.paginate`.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
filters (dict[str, Any] | None): Filter conditions.
|
|
88
|
+
order_by (str | None): Column name to order by.
|
|
89
|
+
page (int): 1-indexed page number.
|
|
90
|
+
page_size (int): Items per page.
|
|
91
|
+
ascending (bool): Whether to order ascending.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
dict[str, Any]: The paginated payload.
|
|
95
|
+
"""
|
|
96
|
+
return await self.service.paginate(
|
|
97
|
+
filters=filters,
|
|
98
|
+
order_by=order_by,
|
|
99
|
+
page=page,
|
|
100
|
+
page_size=page_size,
|
|
101
|
+
ascending=ascending,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def count(self, filters: dict[str, Any] | None = None) -> int:
|
|
105
|
+
"""Pass-through to :meth:`BaseService.count`.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
filters (dict[str, Any] | None): The filter conditions.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
int: The matching row count.
|
|
112
|
+
"""
|
|
113
|
+
return await self.service.count(filters)
|
|
114
|
+
|
|
115
|
+
async def delete(self, id: UUID) -> None:
|
|
116
|
+
"""Pass-through to :meth:`BaseService.delete`.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
id (UUID): The primary key.
|
|
120
|
+
"""
|
|
121
|
+
await self.service.delete(id)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__: list[str] = [
|
|
125
|
+
"BaseController",
|
|
126
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Core cross-cutting primitives: logging, context, configuration."""
|
|
2
|
+
|
|
3
|
+
from tempest_fastapi_sdk.core.context import (
|
|
4
|
+
clear_request_id,
|
|
5
|
+
get_request_id,
|
|
6
|
+
request_id_ctx,
|
|
7
|
+
set_request_id,
|
|
8
|
+
)
|
|
9
|
+
from tempest_fastapi_sdk.core.logging import (
|
|
10
|
+
JSONFormatter,
|
|
11
|
+
configure_logging,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__: list[str] = [
|
|
15
|
+
"JSONFormatter",
|
|
16
|
+
"clear_request_id",
|
|
17
|
+
"configure_logging",
|
|
18
|
+
"get_request_id",
|
|
19
|
+
"request_id_ctx",
|
|
20
|
+
"set_request_id",
|
|
21
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Context variables propagated across the request lifecycle."""
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar, Token
|
|
4
|
+
|
|
5
|
+
request_id_ctx: ContextVar[str | None] = ContextVar(
|
|
6
|
+
"tempest_request_id",
|
|
7
|
+
default=None,
|
|
8
|
+
)
|
|
9
|
+
"""Per-request correlation identifier.
|
|
10
|
+
|
|
11
|
+
Set by :class:`tempest_fastapi_sdk.api.middlewares.RequestIDMiddleware`
|
|
12
|
+
on every inbound HTTP request, consumed by
|
|
13
|
+
:class:`tempest_fastapi_sdk.core.logging.JSONFormatter` so every log
|
|
14
|
+
line carries the originating request ID.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_request_id() -> str | None:
|
|
19
|
+
"""Return the current request ID, or ``None`` outside a request.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
str | None: The active correlation ID, or ``None`` when not set.
|
|
23
|
+
"""
|
|
24
|
+
return request_id_ctx.get()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_request_id(value: str) -> Token[str | None]:
|
|
28
|
+
"""Bind a request ID for the current async context.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
value (str): The correlation identifier to set.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Token[str | None]: A token that can be passed to
|
|
35
|
+
:func:`clear_request_id` to restore the previous value.
|
|
36
|
+
"""
|
|
37
|
+
return request_id_ctx.set(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def clear_request_id(token: Token[str | None]) -> None:
|
|
41
|
+
"""Reset the request ID using the token returned by :func:`set_request_id`.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
token (Token[str | None]): The token obtained from a previous
|
|
45
|
+
:func:`set_request_id` call.
|
|
46
|
+
"""
|
|
47
|
+
request_id_ctx.reset(token)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__: list[str] = [
|
|
51
|
+
"clear_request_id",
|
|
52
|
+
"get_request_id",
|
|
53
|
+
"request_id_ctx",
|
|
54
|
+
"set_request_id",
|
|
55
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Structured JSON logging with request-ID correlation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from tempest_fastapi_sdk.core.context import get_request_id
|
|
10
|
+
|
|
11
|
+
_RESERVED_LOG_FIELDS: frozenset[str] = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"args",
|
|
14
|
+
"created",
|
|
15
|
+
"exc_info",
|
|
16
|
+
"exc_text",
|
|
17
|
+
"filename",
|
|
18
|
+
"funcName",
|
|
19
|
+
"levelname",
|
|
20
|
+
"levelno",
|
|
21
|
+
"lineno",
|
|
22
|
+
"message",
|
|
23
|
+
"module",
|
|
24
|
+
"msecs",
|
|
25
|
+
"msg",
|
|
26
|
+
"name",
|
|
27
|
+
"pathname",
|
|
28
|
+
"process",
|
|
29
|
+
"processName",
|
|
30
|
+
"relativeCreated",
|
|
31
|
+
"stack_info",
|
|
32
|
+
"taskName",
|
|
33
|
+
"thread",
|
|
34
|
+
"threadName",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class JSONFormatter(logging.Formatter):
|
|
40
|
+
"""Render every log record as a single-line JSON object.
|
|
41
|
+
|
|
42
|
+
Standard ``LogRecord`` fields are mapped to ``timestamp``,
|
|
43
|
+
``level``, ``logger`` and ``message``. The current request ID
|
|
44
|
+
(when present) is attached as ``request_id``. Any additional
|
|
45
|
+
keyword passed to the logger via ``extra={...}`` becomes a
|
|
46
|
+
top-level key in the JSON payload.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
50
|
+
"""Serialize ``record`` to JSON.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
record (logging.LogRecord): The record to format.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
str: A JSON document as a single line.
|
|
57
|
+
"""
|
|
58
|
+
timestamp = (
|
|
59
|
+
datetime.fromtimestamp(record.created, tz=UTC)
|
|
60
|
+
.isoformat()
|
|
61
|
+
.replace("+00:00", "Z")
|
|
62
|
+
)
|
|
63
|
+
payload: dict[str, Any] = {
|
|
64
|
+
"timestamp": timestamp,
|
|
65
|
+
"level": record.levelname,
|
|
66
|
+
"logger": record.name,
|
|
67
|
+
"message": record.getMessage(),
|
|
68
|
+
}
|
|
69
|
+
request_id = get_request_id()
|
|
70
|
+
if request_id is not None:
|
|
71
|
+
payload["request_id"] = request_id
|
|
72
|
+
if record.exc_info:
|
|
73
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
74
|
+
for key, value in record.__dict__.items():
|
|
75
|
+
if key in _RESERVED_LOG_FIELDS:
|
|
76
|
+
continue
|
|
77
|
+
if key.startswith("_"):
|
|
78
|
+
continue
|
|
79
|
+
payload[key] = value
|
|
80
|
+
return json.dumps(payload, default=str, ensure_ascii=False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def configure_logging(
|
|
84
|
+
level: str | int = "INFO",
|
|
85
|
+
*,
|
|
86
|
+
json_output: bool = True,
|
|
87
|
+
logger_name: str | None = None,
|
|
88
|
+
) -> logging.Logger:
|
|
89
|
+
"""Install a structured stdout handler on the root (or named) logger.
|
|
90
|
+
|
|
91
|
+
Replaces existing handlers on the target logger so this can be
|
|
92
|
+
called safely from ``create_app`` without stacking duplicates.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
level (str | int): The minimum level to emit (e.g. ``"INFO"``,
|
|
96
|
+
``logging.DEBUG``).
|
|
97
|
+
json_output (bool): When ``True`` (default), emit JSON via
|
|
98
|
+
:class:`JSONFormatter`. When ``False``, fall back to a
|
|
99
|
+
human-readable text formatter — useful in local dev where
|
|
100
|
+
JSON noise overwhelms the terminal.
|
|
101
|
+
logger_name (str | None): The logger to configure. ``None``
|
|
102
|
+
(default) configures the root logger.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
logging.Logger: The configured logger instance.
|
|
106
|
+
"""
|
|
107
|
+
logger = logging.getLogger(logger_name)
|
|
108
|
+
logger.setLevel(level)
|
|
109
|
+
for handler in list(logger.handlers):
|
|
110
|
+
logger.removeHandler(handler)
|
|
111
|
+
|
|
112
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
113
|
+
if json_output:
|
|
114
|
+
handler.setFormatter(JSONFormatter())
|
|
115
|
+
else:
|
|
116
|
+
handler.setFormatter(
|
|
117
|
+
logging.Formatter(
|
|
118
|
+
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
119
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
logger.addHandler(handler)
|
|
123
|
+
logger.propagate = False
|
|
124
|
+
return logger
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__: list[str] = [
|
|
128
|
+
"JSONFormatter",
|
|
129
|
+
"configure_logging",
|
|
130
|
+
]
|