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.
Files changed (91) hide show
  1. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/PKG-INFO +1 -1
  2. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/pyproject.toml +1 -1
  3. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/__init__.py +25 -1
  4. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/api/__init__.py +8 -0
  5. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/api/dependencies/__init__.py +11 -0
  6. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/api/dependencies/auth.py +84 -0
  7. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/api/middlewares/__init__.py +7 -0
  8. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/api/middlewares/request_id.py +66 -0
  9. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/controllers/__init__.py +7 -0
  10. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/controllers/base.py +126 -0
  11. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/core/__init__.py +21 -0
  12. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/core/context.py +55 -0
  13. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/core/logging.py +130 -0
  14. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/repository.py +38 -6
  15. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/services/__init__.py +7 -0
  16. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/services/base.py +165 -0
  17. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/testing/__init__.py +26 -0
  18. tempest_fastapi_sdk-0.3.0/tempest_fastapi_sdk/testing/database.py +158 -0
  19. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/upload.py +14 -3
  20. tempest_fastapi_sdk-0.3.0/tests/api/test_dependencies_auth.py +77 -0
  21. tempest_fastapi_sdk-0.3.0/tests/api/test_request_id_middleware.py +73 -0
  22. tempest_fastapi_sdk-0.3.0/tests/controllers/test_base.py +88 -0
  23. tempest_fastapi_sdk-0.3.0/tests/core/test_context.py +36 -0
  24. tempest_fastapi_sdk-0.3.0/tests/core/test_logging.py +112 -0
  25. tempest_fastapi_sdk-0.3.0/tests/services/__init__.py +0 -0
  26. tempest_fastapi_sdk-0.3.0/tests/services/test_base.py +105 -0
  27. tempest_fastapi_sdk-0.3.0/tests/settings/__init__.py +0 -0
  28. tempest_fastapi_sdk-0.3.0/tests/testing/__init__.py +0 -0
  29. tempest_fastapi_sdk-0.3.0/tests/testing/test_database.py +57 -0
  30. tempest_fastapi_sdk-0.3.0/tests/utils/__init__.py +0 -0
  31. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/uv.lock +1 -1
  32. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/.github/workflows/ci.yml +0 -0
  33. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/.github/workflows/release-pypi.yml +0 -0
  34. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/.gitignore +0 -0
  35. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/.python-version +0 -0
  36. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/Makefile +0 -0
  37. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/README.md +0 -0
  38. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/api/handlers.py +0 -0
  39. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/__init__.py +0 -0
  40. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/_alembic_templates/__init__.py +0 -0
  41. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/_alembic_templates/env.py.template +0 -0
  42. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/connection.py +0 -0
  43. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/migrations.py +0 -0
  44. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/db/model.py +0 -0
  45. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/__init__.py +0 -0
  46. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/base.py +0 -0
  47. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/conflict.py +0 -0
  48. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/forbidden.py +0 -0
  49. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/jwt.py +0 -0
  50. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/not_found.py +0 -0
  51. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/unauthorized.py +0 -0
  52. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/upload.py +0 -0
  53. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/exceptions/validation.py +0 -0
  54. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/schemas/__init__.py +0 -0
  55. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/schemas/base.py +0 -0
  56. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/schemas/pagination.py +0 -0
  57. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/schemas/response.py +0 -0
  58. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/settings/__init__.py +0 -0
  59. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/settings/base.py +0 -0
  60. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/__init__.py +0 -0
  61. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/datetime.py +0 -0
  62. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/dict.py +0 -0
  63. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/email.py +0 -0
  64. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/jwt.py +0 -0
  65. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/password.py +0 -0
  66. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tempest_fastapi_sdk/utils/regex.py +0 -0
  67. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/__init__.py +0 -0
  68. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/api/__init__.py +0 -0
  69. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/api/test_handlers.py +0 -0
  70. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/conftest.py +0 -0
  71. {tempest_fastapi_sdk-0.2.0/tests/db → tempest_fastapi_sdk-0.3.0/tests/controllers}/__init__.py +0 -0
  72. {tempest_fastapi_sdk-0.2.0/tests/exceptions → tempest_fastapi_sdk-0.3.0/tests/core}/__init__.py +0 -0
  73. {tempest_fastapi_sdk-0.2.0/tests/schemas → tempest_fastapi_sdk-0.3.0/tests/db}/__init__.py +0 -0
  74. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/db/test_connection.py +0 -0
  75. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/db/test_migrations.py +0 -0
  76. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/db/test_model.py +0 -0
  77. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/db/test_repository.py +0 -0
  78. {tempest_fastapi_sdk-0.2.0/tests/settings → tempest_fastapi_sdk-0.3.0/tests/exceptions}/__init__.py +0 -0
  79. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/exceptions/test_exceptions.py +0 -0
  80. {tempest_fastapi_sdk-0.2.0/tests/utils → tempest_fastapi_sdk-0.3.0/tests/schemas}/__init__.py +0 -0
  81. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/schemas/test_base.py +0 -0
  82. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/schemas/test_pagination.py +0 -0
  83. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/schemas/test_response.py +0 -0
  84. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/settings/test_base.py +0 -0
  85. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_datetime.py +0 -0
  86. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_dict.py +0 -0
  87. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_email.py +0 -0
  88. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_jwt.py +0 -0
  89. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_password.py +0 -0
  90. {tempest_fastapi_sdk-0.2.0 → tempest_fastapi_sdk-0.3.0}/tests/utils/test_regex.py +0 -0
  91. {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.2.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.2.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.2.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,11 @@
1
+ """FastAPI dependency providers used across SDK consumers."""
2
+
3
+ from tempest_fastapi_sdk.api.dependencies.auth import (
4
+ make_token_dependency,
5
+ require_x_token,
6
+ )
7
+
8
+ __all__: list[str] = [
9
+ "make_token_dependency",
10
+ "require_x_token",
11
+ ]
@@ -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,7 @@
1
+ """Reusable Starlette middlewares for FastAPI services."""
2
+
3
+ from tempest_fastapi_sdk.api.middlewares.request_id import RequestIDMiddleware
4
+
5
+ __all__: list[str] = [
6
+ "RequestIDMiddleware",
7
+ ]
@@ -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,7 @@
1
+ """Controller layer base classes (router ↔ service orchestrators)."""
2
+
3
+ from tempest_fastapi_sdk.controllers.base import BaseController
4
+
5
+ __all__: list[str] = [
6
+ "BaseController",
7
+ ]
@@ -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
+ ]