tempest-fastapi-sdk 0.7.2__tar.gz → 0.7.3__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 (128) hide show
  1. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/PKG-INFO +1 -1
  2. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/pyproject.toml +1 -1
  3. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/__init__.py +1 -1
  4. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/middlewares/request_id.py +17 -1
  5. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/routers/health.py +10 -3
  6. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/db/connection.py +20 -3
  7. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/settings/mixins.py +12 -2
  8. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/sse/event_stream.py +4 -3
  9. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/webpush/dispatcher.py +33 -3
  10. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/webpush/schemas.py +29 -1
  11. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/api/test_health_router.py +19 -0
  12. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/api/test_request_id_middleware.py +23 -0
  13. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/db/test_connection.py +24 -0
  14. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/sse/test_event_stream.py +30 -0
  15. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/webpush/test_schemas.py +18 -0
  16. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/uv.lock +2 -2
  17. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/.github/workflows/ci.yml +0 -0
  18. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/.github/workflows/release-pypi.yml +0 -0
  19. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/.gitignore +0 -0
  20. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/.python-version +0 -0
  21. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/Makefile +0 -0
  22. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/README.md +0 -0
  23. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/__init__.py +0 -0
  24. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/dependencies/__init__.py +0 -0
  25. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/dependencies/auth.py +0 -0
  26. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/handlers.py +0 -0
  27. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/middlewares/__init__.py +0 -0
  28. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/middlewares/cors.py +0 -0
  29. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/api/routers/__init__.py +0 -0
  30. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/cache/__init__.py +0 -0
  31. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/cache/redis_manager.py +0 -0
  32. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/controllers/__init__.py +0 -0
  33. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/controllers/base.py +0 -0
  34. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/core/__init__.py +0 -0
  35. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/core/context.py +0 -0
  36. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/core/logging.py +0 -0
  37. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/db/__init__.py +0 -0
  38. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/db/_alembic_templates/__init__.py +0 -0
  39. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/db/_alembic_templates/env.py.template +0 -0
  40. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/db/migrations.py +0 -0
  41. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/db/mixins.py +0 -0
  42. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/db/model.py +0 -0
  43. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/db/repository.py +0 -0
  44. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/__init__.py +0 -0
  45. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/base.py +0 -0
  46. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/conflict.py +0 -0
  47. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/forbidden.py +0 -0
  48. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/jwt.py +0 -0
  49. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/not_found.py +0 -0
  50. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/unauthorized.py +0 -0
  51. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/upload.py +0 -0
  52. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/exceptions/validation.py +0 -0
  53. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/queue/__init__.py +0 -0
  54. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/queue/manager.py +0 -0
  55. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/schemas/__init__.py +0 -0
  56. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/schemas/base.py +0 -0
  57. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/schemas/pagination.py +0 -0
  58. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/schemas/response.py +0 -0
  59. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/services/__init__.py +0 -0
  60. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/services/base.py +0 -0
  61. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/settings/__init__.py +0 -0
  62. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/settings/base.py +0 -0
  63. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/sse/__init__.py +0 -0
  64. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/tasks/__init__.py +0 -0
  65. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/tasks/manager.py +0 -0
  66. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/testing/__init__.py +0 -0
  67. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/testing/database.py +0 -0
  68. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/__init__.py +0 -0
  69. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/datetime.py +0 -0
  70. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/dict.py +0 -0
  71. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/email.py +0 -0
  72. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/jwt.py +0 -0
  73. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/log.py +0 -0
  74. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/metrics.py +0 -0
  75. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/password.py +0 -0
  76. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/regex.py +0 -0
  77. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/utils/upload.py +0 -0
  78. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tempest_fastapi_sdk/webpush/__init__.py +0 -0
  79. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/__init__.py +0 -0
  80. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/api/__init__.py +0 -0
  81. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/api/test_cors.py +0 -0
  82. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/api/test_dependencies_auth.py +0 -0
  83. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/api/test_handlers.py +0 -0
  84. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/cache/__init__.py +0 -0
  85. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/cache/test_redis_manager.py +0 -0
  86. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/conftest.py +0 -0
  87. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/controllers/__init__.py +0 -0
  88. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/controllers/test_base.py +0 -0
  89. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/core/__init__.py +0 -0
  90. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/core/test_context.py +0 -0
  91. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/core/test_logging.py +0 -0
  92. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/db/__init__.py +0 -0
  93. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/db/test_migrations.py +0 -0
  94. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/db/test_mixins.py +0 -0
  95. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/db/test_model.py +0 -0
  96. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/db/test_repository.py +0 -0
  97. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/exceptions/__init__.py +0 -0
  98. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/exceptions/test_exceptions.py +0 -0
  99. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/queue/__init__.py +0 -0
  100. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/queue/test_manager.py +0 -0
  101. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/schemas/__init__.py +0 -0
  102. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/schemas/test_base.py +0 -0
  103. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/schemas/test_cursor_pagination.py +0 -0
  104. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/schemas/test_pagination.py +0 -0
  105. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/schemas/test_response.py +0 -0
  106. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/services/__init__.py +0 -0
  107. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/services/test_base.py +0 -0
  108. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/settings/__init__.py +0 -0
  109. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/settings/test_base.py +0 -0
  110. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/settings/test_mixins.py +0 -0
  111. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/sse/__init__.py +0 -0
  112. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/tasks/__init__.py +0 -0
  113. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/tasks/test_manager.py +0 -0
  114. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/testing/__init__.py +0 -0
  115. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/testing/test_database.py +0 -0
  116. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/__init__.py +0 -0
  117. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_datetime.py +0 -0
  118. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_dict.py +0 -0
  119. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_email.py +0 -0
  120. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_jwt.py +0 -0
  121. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_lazy_extras.py +0 -0
  122. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_log.py +0 -0
  123. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_metrics.py +0 -0
  124. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_password.py +0 -0
  125. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_regex.py +0 -0
  126. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/utils/test_upload.py +0 -0
  127. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/webpush/__init__.py +0 -0
  128. {tempest_fastapi_sdk-0.7.2 → tempest_fastapi_sdk-0.7.3}/tests/webpush/test_dispatcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tempest-fastapi-sdk
3
- Version: 0.7.2
3
+ Version: 0.7.3
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.7.2"
3
+ version = "0.7.3"
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"
@@ -103,7 +103,7 @@ from tempest_fastapi_sdk.webpush import (
103
103
  WebPushSubscriptionSchema,
104
104
  )
105
105
 
106
- __version__: str = "0.7.2"
106
+ __version__: str = "0.7.3"
107
107
 
108
108
  __all__: list[str] = [
109
109
  "CNPJ",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import uuid
6
7
  from collections.abc import Awaitable, Callable
7
8
 
@@ -12,6 +13,16 @@ from starlette.types import ASGIApp
12
13
 
13
14
  from tempest_fastapi_sdk.core.context import clear_request_id, set_request_id
14
15
 
16
+ _VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._\-:+/=]{1,128}$")
17
+ """Whitelist for inbound request IDs.
18
+
19
+ Restricts the header value to printable ASCII without whitespace or
20
+ control characters so it cannot be used to forge log lines (CRLF
21
+ injection inside :class:`JSONFormatter`) or response headers when
22
+ echoed back. Generous enough to cover UUIDs, ULIDs, base64 trace
23
+ IDs, and most OpenTelemetry trace identifiers.
24
+ """
25
+
15
26
 
16
27
  class RequestIDMiddleware(BaseHTTPMiddleware):
17
28
  """Bind an ``X-Request-ID`` header to the request-scoped context.
@@ -51,7 +62,12 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
51
62
  Response: The handler's response with the request ID
52
63
  echoed in the configured header.
53
64
  """
54
- rid = request.headers.get(self.header_name) or str(uuid.uuid4())
65
+ inbound = request.headers.get(self.header_name)
66
+ rid = (
67
+ inbound
68
+ if inbound is not None and _VALID_REQUEST_ID.fullmatch(inbound)
69
+ else str(uuid.uuid4())
70
+ )
55
71
  token = set_request_id(rid)
56
72
  try:
57
73
  response = await call_next(request)
@@ -29,6 +29,7 @@ def make_health_router(
29
29
  prefix: str = "/health",
30
30
  tag: str = "health",
31
31
  version: str | None = None,
32
+ expose_checks: bool = True,
32
33
  ) -> APIRouter:
33
34
  """Build the canonical ``/health`` router.
34
35
 
@@ -40,8 +41,8 @@ def make_health_router(
40
41
  liveness probes as "restart the pod"). This endpoint takes
41
42
  precedence over readiness for that reason.
42
43
  * ``GET <prefix>/readiness`` — runs every configured check and
43
- returns ``200`` only when all pass. Returns ``503`` with the
44
- per-check breakdown when at least one fails.
44
+ returns ``200`` only when all pass. Returns ``503`` when at
45
+ least one fails.
45
46
 
46
47
  Args:
47
48
  db (AsyncDatabaseManager | None): When provided, a
@@ -55,6 +56,11 @@ def make_health_router(
55
56
  tag (str): OpenAPI tag applied to both endpoints.
56
57
  version (str | None): When provided, attached to the
57
58
  readiness payload as ``version``.
59
+ expose_checks (bool): Whether to surface the per-dependency
60
+ breakdown in the readiness payload. Defaults to ``True``
61
+ for development ergonomics; set ``False`` in production
62
+ so unauthenticated probes don't reveal which backends
63
+ (database, Redis, RabbitMQ, etc.) the service depends on.
58
64
 
59
65
  Returns:
60
66
  APIRouter: A router ready to ``include_router(...)`` on the
@@ -96,8 +102,9 @@ def make_health_router(
96
102
  overall = all(results.values()) if results else True
97
103
  payload: dict[str, Any] = {
98
104
  "status": "ready" if overall else "not_ready",
99
- "checks": results,
100
105
  }
106
+ if expose_checks:
107
+ payload["checks"] = results
101
108
  if version is not None:
102
109
  payload["version"] = version
103
110
  return JSONResponse(
@@ -31,8 +31,11 @@ class AsyncDatabaseManager:
31
31
  relying on substring tricks.
32
32
 
33
33
  Attributes:
34
- db_url (str): The database connection URL.
35
34
  is_sqlite (bool): Whether the URL targets a SQLite backend.
35
+
36
+ The connection URL itself is stored on a private attribute so it
37
+ never leaks through ``repr()`` or accidental logging. Use the
38
+ :attr:`db_url_safe` property when a redacted form is needed.
36
39
  """
37
40
 
38
41
  def __init__(
@@ -69,7 +72,7 @@ class AsyncDatabaseManager:
69
72
  **engine_kwargs: Any additional keyword arguments are
70
73
  passed through to ``create_async_engine`` verbatim.
71
74
  """
72
- self.db_url: str = db_url
75
+ self._db_url: str = db_url
73
76
  self.is_sqlite: bool = make_url(db_url).get_backend_name() == "sqlite"
74
77
  self._echo: bool = echo
75
78
  self._pool_size: int = pool_size
@@ -81,6 +84,20 @@ class AsyncDatabaseManager:
81
84
  self._engine: AsyncEngine | None = None
82
85
  self._session_maker: async_sessionmaker[AsyncSession] | None = None
83
86
 
87
+ @property
88
+ def db_url_safe(self) -> str:
89
+ """Return the URL with credentials masked.
90
+
91
+ Useful for diagnostics, health payloads or log lines —
92
+ ``postgresql+asyncpg://user:pass@host/db`` becomes
93
+ ``postgresql+asyncpg://***@host/db``.
94
+
95
+ Returns:
96
+ str: The URL safe to surface outside the manager.
97
+ """
98
+ url = make_url(self._db_url)
99
+ return url.render_as_string(hide_password=True)
100
+
84
101
  @property
85
102
  def is_connected(self) -> bool:
86
103
  """Whether the engine is currently initialized.
@@ -115,7 +132,7 @@ class AsyncDatabaseManager:
115
132
  if self._poolclass is not None:
116
133
  kwargs["poolclass"] = self._poolclass
117
134
 
118
- self._engine = create_async_engine(self.db_url, **kwargs)
135
+ self._engine = create_async_engine(self._db_url, **kwargs)
119
136
  self._session_maker = async_sessionmaker(
120
137
  self._engine,
121
138
  expire_on_commit=False,
@@ -123,9 +123,19 @@ class JWTSettings(BaseSettings):
123
123
  class CORSSettings(BaseSettings):
124
124
  """CORS middleware configuration.
125
125
 
126
+ .. warning::
127
+ The default ``CORS_ORIGINS=["*"]`` is permissive on purpose
128
+ so local development works out of the box. **Never** ship
129
+ this default to production — set ``CORS_ORIGINS`` to the
130
+ explicit list of trusted frontend origins (e.g.
131
+ ``["https://app.example.com"]``) in your production
132
+ configuration. ``"*"`` is also incompatible with
133
+ ``CORS_ALLOW_CREDENTIALS=True`` (browsers ignore credentialed
134
+ requests sent to a wildcard origin).
135
+
126
136
  Attributes:
127
- CORS_ORIGINS (list[str]): Allowed origins. Use ``["*"]`` only
128
- in dev disables credentialed requests in browsers.
137
+ CORS_ORIGINS (list[str]): Allowed origins. **Override in
138
+ production.** Defaults to ``["*"]`` for development only.
129
139
  CORS_ALLOW_CREDENTIALS (bool): Whether to allow cookies/auth
130
140
  headers cross-origin.
131
141
  CORS_ALLOW_METHODS (list[str]): Allowed HTTP methods.
@@ -176,8 +176,9 @@ def sse_response(
176
176
  Adds the SSE-specific headers (``Cache-Control: no-cache``,
177
177
  ``Connection: keep-alive``, ``X-Accel-Buffering: no``) so
178
178
  intermediate proxies don't buffer or cache the long-lived
179
- response. Custom headers passed via ``headers`` are merged on
180
- top of the defaults.
179
+ response. Caller-supplied ``headers`` are layered **below** the
180
+ SSE defaults so the three critical headers above cannot be
181
+ accidentally overridden — pass extra metadata, not replacements.
181
182
 
182
183
  Args:
183
184
  stream (AsyncIterable[bytes]): The byte stream produced by
@@ -188,7 +189,7 @@ def sse_response(
188
189
  Returns:
189
190
  StreamingResponse: A ready-to-return SSE response.
190
191
  """
191
- merged: dict[str, str] = {**_SSE_HEADERS, **(headers or {})}
192
+ merged: dict[str, str] = {**(headers or {}), **_SSE_HEADERS}
192
193
  return StreamingResponse(
193
194
  stream,
194
195
  media_type="text/event-stream",
@@ -3,9 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import hashlib
6
7
  import json
7
8
  import logging
8
9
  from typing import Any
10
+ from urllib.parse import urlsplit
9
11
 
10
12
  from tempest_fastapi_sdk.webpush.schemas import (
11
13
  WebPushPayloadSchema,
@@ -15,6 +17,29 @@ from tempest_fastapi_sdk.webpush.schemas import (
15
17
  logger = logging.getLogger(__name__)
16
18
 
17
19
 
20
+ def _mask_endpoint(endpoint: str) -> str:
21
+ """Return a stable but non-sensitive identifier for an endpoint.
22
+
23
+ Keeps the host (useful for routing errors to the right push
24
+ service) and replaces the full path + query with a short SHA-256
25
+ prefix so subscription tokens never reach logs or API responses.
26
+
27
+ Args:
28
+ endpoint (str): The full push service URL.
29
+
30
+ Returns:
31
+ str: ``<host>/<sha256-prefix>`` or the original string when it
32
+ cannot be parsed.
33
+ """
34
+ try:
35
+ parsed = urlsplit(endpoint)
36
+ except ValueError:
37
+ return "<unparsable-endpoint>"
38
+ host = parsed.netloc or "<unknown-host>"
39
+ digest = hashlib.sha256(endpoint.encode("utf-8")).hexdigest()[:12]
40
+ return f"{host}/{digest}"
41
+
42
+
18
43
  class WebPushError(RuntimeError):
19
44
  """Raised when a push delivery attempt fails irrecoverably.
20
45
 
@@ -169,14 +194,15 @@ class WebPushDispatcher:
169
194
  )
170
195
  except pywebpush.WebPushException as exc:
171
196
  status = exc.response.status_code if exc.response is not None else None
197
+ masked = _mask_endpoint(subscription.endpoint)
172
198
  if status in {404, 410}:
173
199
  raise WebPushGoneError(
174
- f"Subscription gone (HTTP {status})",
200
+ f"Subscription gone (HTTP {status}) for {masked}",
175
201
  status_code=status,
176
202
  endpoint=subscription.endpoint,
177
203
  ) from exc
178
204
  raise WebPushError(
179
- f"Web Push delivery failed: {exc}",
205
+ f"Web Push delivery failed for {masked} (HTTP {status})",
180
206
  status_code=status,
181
207
  endpoint=subscription.endpoint,
182
208
  ) from exc
@@ -216,7 +242,11 @@ class WebPushDispatcher:
216
242
  except WebPushGoneError:
217
243
  gone.append(sub.endpoint)
218
244
  except WebPushError as exc:
219
- logger.warning("Web Push send failed for %s: %s", sub.endpoint, exc)
245
+ logger.warning(
246
+ "Web Push send failed for %s: %s",
247
+ _mask_endpoint(sub.endpoint),
248
+ exc,
249
+ )
220
250
 
221
251
  await asyncio.gather(*(_one(sub) for sub in subscriptions))
222
252
  return gone
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
+ from urllib.parse import urlsplit
6
7
 
7
- from pydantic import ConfigDict, Field
8
+ from pydantic import ConfigDict, Field, field_validator
8
9
 
9
10
  from tempest_fastapi_sdk.schemas.base import BaseSchema
10
11
 
@@ -58,6 +59,33 @@ class WebPushSubscriptionSchema(BaseSchema):
58
59
  description="Optional expiration time (ms since epoch).",
59
60
  )
60
61
 
62
+ @field_validator("endpoint")
63
+ @classmethod
64
+ def _endpoint_must_be_https(cls, value: str) -> str:
65
+ """Reject endpoints that aren't ``https://`` URLs.
66
+
67
+ The Web Push spec requires HTTPS, and accepting arbitrary
68
+ schemes (``file://``, ``http://localhost``, etc.) would turn
69
+ the server into an SSRF proxy when subscriptions come from
70
+ untrusted clients.
71
+
72
+ Args:
73
+ value (str): The candidate endpoint URL.
74
+
75
+ Returns:
76
+ str: The same URL when valid.
77
+
78
+ Raises:
79
+ ValueError: When the URL is malformed or not HTTPS.
80
+ """
81
+ try:
82
+ parsed = urlsplit(value)
83
+ except ValueError as exc:
84
+ raise ValueError("Invalid endpoint URL") from exc
85
+ if parsed.scheme != "https" or not parsed.netloc:
86
+ raise ValueError("Push endpoints must be HTTPS URLs.")
87
+ return value
88
+
61
89
 
62
90
  class WebPushPayloadSchema(BaseSchema):
63
91
  """Optional helper for the JSON payload delivered with each push.
@@ -76,3 +76,22 @@ async def test_custom_prefix() -> None:
76
76
  async with _client(app) as client:
77
77
  liveness = await client.get("/ops/health/liveness")
78
78
  assert liveness.status_code == 200
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_readiness_hides_checks_when_expose_checks_false() -> None:
83
+ """Production deployments can hide the per-dependency breakdown."""
84
+
85
+ async def db_check() -> bool:
86
+ return False
87
+
88
+ app = FastAPI()
89
+ app.include_router(
90
+ make_health_router(checks={"database": db_check}, expose_checks=False),
91
+ )
92
+ async with _client(app) as client:
93
+ response = await client.get("/health/readiness")
94
+ body = response.json()
95
+ assert response.status_code == 503
96
+ assert body["status"] == "not_ready"
97
+ assert "checks" not in body
@@ -54,6 +54,29 @@ async def test_context_is_isolated_between_requests() -> None:
54
54
  assert get_request_id() is None
55
55
 
56
56
 
57
+ @pytest.mark.asyncio
58
+ @pytest.mark.parametrize(
59
+ "malicious",
60
+ [
61
+ "abc\r\nX-Injected: 1",
62
+ "abc\nlevel=CRITICAL",
63
+ "abc with spaces",
64
+ "abc;rm -rf /",
65
+ "x" * 200, # exceeds 128-char cap
66
+ ],
67
+ )
68
+ async def test_malicious_header_replaced_with_uuid(malicious: str) -> None:
69
+ """Reject CRLF and other unsafe characters to block log/header injection."""
70
+ app = _make_app()
71
+ transport = ASGITransport(app=app)
72
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
73
+ response = await client.get("/echo", headers={"X-Request-ID": malicious})
74
+ rid = response.headers["X-Request-ID"]
75
+ uuid.UUID(rid) # must be a fresh UUID, not the attacker's value
76
+ assert rid != malicious
77
+ assert response.json()["request_id"] == rid
78
+
79
+
57
80
  @pytest.mark.asyncio
58
81
  async def test_custom_header_name() -> None:
59
82
  app = FastAPI()
@@ -120,6 +120,30 @@ class TestHealthCheck:
120
120
  await manager.disconnect()
121
121
 
122
122
 
123
+ class TestUrlMasking:
124
+ def test_db_url_safe_hides_password(self) -> None:
125
+ manager = AsyncDatabaseManager(
126
+ "postgresql+asyncpg://alice:supersecret@db.internal:5432/app",
127
+ )
128
+ safe = manager.db_url_safe
129
+ assert "supersecret" not in safe
130
+ assert "alice" in safe
131
+ assert "db.internal" in safe
132
+
133
+ def test_db_url_safe_handles_url_without_password(self) -> None:
134
+ manager = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
135
+ assert manager.db_url_safe.startswith("sqlite+aiosqlite://")
136
+
137
+ def test_db_url_no_public_attribute(self) -> None:
138
+ manager = AsyncDatabaseManager(
139
+ "postgresql+asyncpg://alice:supersecret@db.internal:5432/app",
140
+ )
141
+ # Credentials must not be reachable via a public attribute.
142
+ assert not hasattr(manager, "db_url") or "supersecret" not in str(
143
+ getattr(manager, "db_url", "")
144
+ )
145
+
146
+
123
147
  class TestRequireConnected:
124
148
  async def test_session_methods_lazy_connect(self) -> None:
125
149
  # get_session / get_session_context / session_dependency all
@@ -1,6 +1,7 @@
1
1
  """Tests for tempest_fastapi_sdk.sse."""
2
2
 
3
3
  import asyncio
4
+ from collections.abc import AsyncIterator
4
5
 
5
6
  import pytest
6
7
  from fastapi import FastAPI
@@ -98,3 +99,32 @@ async def test_sse_response_end_to_end() -> None:
98
99
  assert b"event: ping" in response.content
99
100
  assert b"data: hello" in response.content
100
101
  assert b"data: world" in response.content
102
+
103
+
104
+ async def test_sse_response_caller_headers_cannot_override_defaults() -> None:
105
+ """Caller-supplied headers must not override SSE-critical headers."""
106
+ from fastapi import FastAPI
107
+
108
+ app = FastAPI()
109
+
110
+ async def empty() -> AsyncIterator[bytes]:
111
+ if False: # pragma: no cover - generator never yields
112
+ yield b""
113
+
114
+ @app.get("/events")
115
+ async def events() -> object:
116
+ return sse_response(
117
+ empty(),
118
+ headers={
119
+ "Cache-Control": "public, max-age=3600",
120
+ "X-Accel-Buffering": "yes",
121
+ "X-Custom": "ok",
122
+ },
123
+ )
124
+
125
+ transport = ASGITransport(app=app)
126
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
127
+ response = await client.get("/events")
128
+ assert response.headers["cache-control"] == "no-cache"
129
+ assert response.headers["x-accel-buffering"] == "no"
130
+ assert response.headers["x-custom"] == "ok"
@@ -45,6 +45,24 @@ class TestWebPushSubscription:
45
45
  keys=WebPushKeysSchema(p256dh="pk", auth="a"),
46
46
  )
47
47
 
48
+ @pytest.mark.parametrize(
49
+ "endpoint",
50
+ [
51
+ "http://push.example.com/abc",
52
+ "file:///etc/passwd",
53
+ "ftp://push.example.com/abc",
54
+ "javascript:alert(1)",
55
+ "not-a-url",
56
+ ],
57
+ )
58
+ def test_non_https_endpoint_rejected(self, endpoint: str) -> None:
59
+ """Reject non-HTTPS endpoints to prevent SSRF via subscriptions."""
60
+ with pytest.raises(ValueError):
61
+ WebPushSubscriptionSchema(
62
+ endpoint=endpoint,
63
+ keys=WebPushKeysSchema(p256dh="pk", auth="a"),
64
+ )
65
+
48
66
 
49
67
  class TestWebPushPayload:
50
68
  def test_all_fields_optional(self) -> None:
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 2
3
3
  requires-python = ">=3.11"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.15'",
@@ -1948,7 +1948,7 @@ wheels = [
1948
1948
 
1949
1949
  [[package]]
1950
1950
  name = "tempest-fastapi-sdk"
1951
- version = "0.7.2"
1951
+ version = "0.7.3"
1952
1952
  source = { editable = "." }
1953
1953
  dependencies = [
1954
1954
  { name = "alembic" },