fastapi-forge-cli 0.1.0__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.
Files changed (181) hide show
  1. fastapi_forge/__init__.py +7 -0
  2. fastapi_forge/__main__.py +6 -0
  3. fastapi_forge/cli.py +211 -0
  4. fastapi_forge/templates/with_rbac/Dockerfile +31 -0
  5. fastapi_forge/templates/with_rbac/README.md +121 -0
  6. fastapi_forge/templates/with_rbac/_dockerignore +16 -0
  7. fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
  8. fastapi_forge/templates/with_rbac/_gitignore +19 -0
  9. fastapi_forge/templates/with_rbac/alembic/README +1 -0
  10. fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
  11. fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
  12. fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
  13. fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
  14. fastapi_forge/templates/with_rbac/alembic.ini +35 -0
  15. fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
  16. fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
  17. fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
  18. fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
  19. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  20. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
  21. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
  22. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
  23. fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
  24. fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
  25. fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
  26. fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
  27. fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
  28. fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
  29. fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
  30. fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
  31. fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
  32. fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
  33. fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
  34. fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
  35. fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
  36. fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
  37. fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
  38. fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
  39. fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
  40. fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
  41. fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
  42. fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
  43. fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
  44. fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
  45. fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
  46. fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
  47. fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
  48. fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
  49. fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
  50. fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
  51. fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
  52. fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
  53. fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
  54. fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
  55. fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
  56. fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
  57. fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
  58. fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
  59. fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
  60. fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
  61. fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
  62. fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
  63. fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
  64. fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
  65. fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
  66. fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
  67. fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
  68. fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
  69. fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
  70. fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
  71. fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
  72. fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
  73. fastapi_forge/templates/with_rbac/app/main.py +66 -0
  74. fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
  75. fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
  76. fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
  77. fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
  78. fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
  79. fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
  80. fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
  81. fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
  82. fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
  83. fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
  84. fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
  85. fastapi_forge/templates/with_rbac/compose.yaml +33 -0
  86. fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
  87. fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
  88. fastapi_forge/templates/with_rbac/requirements.txt +16 -0
  89. fastapi_forge/templates/with_rbac/sample.env +42 -0
  90. fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
  91. fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
  92. fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
  93. fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
  94. fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
  95. fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
  96. fastapi_forge/templates/without_rbac/Dockerfile +31 -0
  97. fastapi_forge/templates/without_rbac/README.md +106 -0
  98. fastapi_forge/templates/without_rbac/_dockerignore +16 -0
  99. fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
  100. fastapi_forge/templates/without_rbac/_gitignore +19 -0
  101. fastapi_forge/templates/without_rbac/alembic/README +1 -0
  102. fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
  103. fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
  104. fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
  105. fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
  106. fastapi_forge/templates/without_rbac/alembic.ini +35 -0
  107. fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
  108. fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
  109. fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
  110. fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
  111. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  112. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
  113. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
  114. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
  115. fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
  116. fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
  117. fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
  118. fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
  119. fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
  120. fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
  121. fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
  122. fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
  123. fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
  124. fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
  125. fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
  126. fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
  127. fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
  128. fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
  129. fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
  130. fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
  131. fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
  132. fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
  133. fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
  134. fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
  135. fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
  136. fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
  137. fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
  138. fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
  139. fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
  140. fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
  141. fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
  142. fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
  143. fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
  144. fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
  145. fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
  146. fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
  147. fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
  148. fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
  149. fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
  150. fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
  151. fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
  152. fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
  153. fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
  154. fastapi_forge/templates/without_rbac/app/main.py +66 -0
  155. fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
  156. fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
  157. fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
  158. fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
  159. fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
  160. fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
  161. fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
  162. fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
  163. fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
  164. fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
  165. fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
  166. fastapi_forge/templates/without_rbac/compose.yaml +33 -0
  167. fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
  168. fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
  169. fastapi_forge/templates/without_rbac/requirements.txt +16 -0
  170. fastapi_forge/templates/without_rbac/sample.env +42 -0
  171. fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
  172. fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
  173. fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
  174. fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
  175. fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
  176. fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
  177. fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
  178. fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
  179. fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  180. fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
  181. fastapi_forge_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,162 @@
1
+ from typing import Any, Optional
2
+ from fastapi import Request, status
3
+ from fastapi.exceptions import RequestValidationError
4
+ from fastapi.responses import JSONResponse
5
+ from starlette.exceptions import HTTPException as StarletteHTTPException
6
+
7
+ from app.core.logging import get_logger
8
+ from app.core.responses import error_response
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ # ── Custom Exception Base ────────────────────────────────────────
14
+ class AppException(Exception):
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
19
+ error_code: Optional[str] = None,
20
+ details: Optional[Any] = None,
21
+ ):
22
+ self.message = message
23
+ self.status_code = status_code
24
+ self.error_code = error_code or f"ERR_{status_code}"
25
+ self.details = details
26
+ super().__init__(message)
27
+
28
+
29
+ # ── Domain-specific Exceptions ───────────────────────────────────
30
+ class NotFoundException(AppException):
31
+ def __init__(self, resource: str = "Resource", resource_id: Any = None):
32
+ msg = f"{resource} not found"
33
+ if resource_id:
34
+ msg = f"{resource} with id '{resource_id}' not found"
35
+ super().__init__(msg, status.HTTP_404_NOT_FOUND, "NOT_FOUND")
36
+
37
+
38
+ class UnauthorizedException(AppException):
39
+ def __init__(self, message: str = "Authentication required"):
40
+ super().__init__(message, status.HTTP_401_UNAUTHORIZED, "UNAUTHORIZED")
41
+
42
+
43
+ class ForbiddenException(AppException):
44
+ def __init__(self, message: str = "Permission denied"):
45
+ super().__init__(message, status.HTTP_403_FORBIDDEN, "FORBIDDEN")
46
+
47
+
48
+ class ConflictException(AppException):
49
+ def __init__(self, message: str = "Resource already exists"):
50
+ super().__init__(message, status.HTTP_409_CONFLICT, "CONFLICT")
51
+
52
+
53
+ class ValidationException(AppException):
54
+ def __init__(self, message: str = "Validation failed", details: Any = None):
55
+ super().__init__(
56
+ message, status.HTTP_422_UNPROCESSABLE_ENTITY, "VALIDATION_ERROR", details
57
+ )
58
+
59
+
60
+ class RateLimitException(AppException):
61
+ def __init__(self, message: str = "Too many requests"):
62
+ super().__init__(
63
+ message, status.HTTP_429_TOO_MANY_REQUESTS, "RATE_LIMIT_EXCEEDED"
64
+ )
65
+
66
+
67
+ class ServiceUnavailableException(AppException):
68
+ def __init__(self, message: str = "Service temporarily unavailable"):
69
+ super().__init__(
70
+ message, status.HTTP_503_SERVICE_UNAVAILABLE, "SERVICE_UNAVAILABLE"
71
+ )
72
+
73
+
74
+ # ── Exception Handlers ───────────────────────────────────────────
75
+ async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
76
+ logger.error(
77
+ "Application exception",
78
+ error_code=exc.error_code,
79
+ message=exc.message,
80
+ status_code=exc.status_code,
81
+ path=str(request.url),
82
+ )
83
+ return JSONResponse(
84
+ status_code=exc.status_code,
85
+ content=error_response(
86
+ message=exc.message,
87
+ error_code=exc.error_code,
88
+ details=exc.details,
89
+ request_id=getattr(request.state, "request_id", None),
90
+ ).model_dump(by_alias=True),
91
+ )
92
+
93
+
94
+ async def http_exception_handler(
95
+ request: Request, exc: StarletteHTTPException
96
+ ) -> JSONResponse:
97
+ logger.warning(
98
+ "HTTP exception",
99
+ status_code=exc.status_code,
100
+ detail=exc.detail,
101
+ path=str(request.url),
102
+ )
103
+ return JSONResponse(
104
+ status_code=exc.status_code,
105
+ content=error_response(
106
+ message=str(exc.detail),
107
+ error_code=f"HTTP_{exc.status_code}",
108
+ request_id=getattr(request.state, "request_id", None),
109
+ ).model_dump(by_alias=True),
110
+ )
111
+
112
+
113
+ async def validation_exception_handler(
114
+ request: Request, exc: RequestValidationError
115
+ ) -> JSONResponse:
116
+ errors = []
117
+ for error in exc.errors():
118
+ errors.append(
119
+ {
120
+ "field": " -> ".join(str(loc) for loc in error["loc"]),
121
+ "message": error["msg"],
122
+ "type": error["type"],
123
+ }
124
+ )
125
+
126
+ logger.warning(
127
+ "Request validation failed",
128
+ errors=errors,
129
+ path=str(request.url),
130
+ )
131
+ return JSONResponse(
132
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
133
+ content=error_response(
134
+ message="Request validation failed",
135
+ error_code="VALIDATION_ERROR",
136
+ details=errors,
137
+ request_id=getattr(request.state, "request_id", None),
138
+ ).model_dump(by_alias=True),
139
+ )
140
+
141
+
142
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
143
+ logger.exception(
144
+ "Unhandled exception",
145
+ exc_info=exc,
146
+ path=str(request.url),
147
+ )
148
+ return JSONResponse(
149
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
150
+ content=error_response(
151
+ message="An unexpected error occurred",
152
+ error_code="INTERNAL_SERVER_ERROR",
153
+ request_id=getattr(request.state, "request_id", None),
154
+ ).model_dump(by_alias=True),
155
+ )
156
+
157
+
158
+ def register_exception_handlers(app) -> None:
159
+ app.add_exception_handler(AppException, app_exception_handler)
160
+ app.add_exception_handler(StarletteHTTPException, http_exception_handler)
161
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
162
+ app.add_exception_handler(Exception, unhandled_exception_handler)
@@ -0,0 +1,231 @@
1
+ import logging
2
+ import logging.handlers
3
+ import sys
4
+ import uuid
5
+ from contextvars import ContextVar
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ import structlog
10
+ from app.core.config import settings
11
+
12
+ # ── Context var to carry request-id across async tasks ──────────
13
+ request_id_ctx: ContextVar[Optional[str]] = ContextVar("request_id", default=None)
14
+
15
+
16
+ def get_request_id() -> str:
17
+ return request_id_ctx.get() or str(uuid.uuid4())
18
+
19
+
20
+ def set_request_id(request_id: str) -> None:
21
+ request_id_ctx.set(request_id)
22
+
23
+
24
+ # ── Add request_id to every log record automatically ────────────
25
+ def add_request_id(logger, method, event_dict):
26
+ event_dict["request_id"] = get_request_id()
27
+ return event_dict
28
+
29
+
30
+ def add_app_info(logger, method, event_dict):
31
+ event_dict["app"] = settings.APP_NAME
32
+ event_dict["env"] = settings.APP_ENV
33
+ return event_dict
34
+
35
+
36
+ # ── Log level filter ───────────────────────────────────────────
37
+ def level_filter(levels: list[int]):
38
+ """Return a logging filter function that passes only specified log levels."""
39
+ allowed_levels = set(levels)
40
+
41
+ def filter_record(record: logging.LogRecord) -> bool:
42
+ return record.levelno in allowed_levels
43
+
44
+ return filter_record
45
+
46
+
47
+ # ── Build daily rotating file handler ─────────────────────────
48
+ def _build_file_handler(
49
+ log_dir: str, level: int, label: str, formatter: logging.Formatter
50
+ ) -> logging.Handler:
51
+ """
52
+ TimedRotatingFileHandler that writes to <log_dir>/YYYY-MM-DD_<label>.log.
53
+ The date prefix is embedded in the *base* filename so every day's file
54
+ carries the date as a prefix rather than a suffix.
55
+ """
56
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
57
+
58
+ # Create date-wise log directory.
59
+ date_dir = Path(log_dir) / today
60
+ date_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ # Example:
63
+ # logs/2026-07-29/error.log
64
+ path = date_dir / f"{label}.log"
65
+
66
+ handler = logging.handlers.TimedRotatingFileHandler(
67
+ filename=str(path),
68
+ when="midnight",
69
+ interval=1,
70
+ backupCount=30,
71
+ utc=True,
72
+ encoding="utf-8",
73
+ )
74
+
75
+ handler.suffix = "%Y-%m-%d"
76
+ handler.setLevel(level)
77
+ handler.setFormatter(formatter)
78
+ return handler
79
+
80
+
81
+ # ── Configure per-level file logging ──────────────────────────
82
+ def _setup_file_logging(
83
+ log_dir: str, root_level: int, formatter: logging.Formatter
84
+ ) -> None:
85
+ """Attach per-level file handlers to the root stdlib logger."""
86
+
87
+ Path(log_dir).mkdir(parents=True, exist_ok=True)
88
+
89
+ root = logging.getLogger()
90
+
91
+ # Remove previously configured application file handlers.
92
+ # This prevents duplicate logs when configure_logging() is
93
+ # called more than once, which can happen with reloaders.
94
+ for handler in root.handlers[:]:
95
+ if getattr(handler, "_app_file_handler", False):
96
+ root.removeHandler(handler)
97
+ handler.close()
98
+
99
+ level_map = [
100
+ (logging.DEBUG, [logging.DEBUG], "debug"),
101
+ (logging.INFO, [logging.INFO], "info"),
102
+ (logging.WARNING, [logging.WARNING], "warn"),
103
+ (logging.ERROR, [logging.ERROR, logging.CRITICAL], "error"),
104
+ ]
105
+
106
+ for min_level, filter_levels, label in level_map:
107
+ # Do not create files below the configured log level.
108
+ if min_level < root_level:
109
+ continue
110
+
111
+ handler = _build_file_handler(
112
+ log_dir=log_dir, level=min_level, label=label, formatter=formatter
113
+ )
114
+
115
+ # Mark handler so it can be identified and removed later.
116
+ handler._app_file_handler = True
117
+
118
+ # Only allow the exact levels assigned to this file.
119
+ handler.addFilter(level_filter(filter_levels))
120
+ root.addHandler(handler)
121
+
122
+
123
+ # ── Logging configuration ─────────────────────────────────────
124
+ def configure_logging() -> None:
125
+ """
126
+ Configure Structlog, console logging, file logging,
127
+ and third-party library logging.
128
+ """
129
+
130
+ log_level = logging.DEBUG if settings.APP_DEBUG else logging.INFO
131
+
132
+ common_shared_processors = [
133
+ structlog.stdlib.add_logger_name,
134
+ structlog.stdlib.add_log_level,
135
+ structlog.processors.TimeStamper(fmt="iso"),
136
+ structlog.processors.StackInfoRenderer(),
137
+ structlog.processors.format_exc_info,
138
+ ]
139
+
140
+ shared_processors = common_shared_processors + [
141
+ structlog.contextvars.merge_contextvars,
142
+ add_request_id,
143
+ add_app_info,
144
+ ]
145
+
146
+ # ── Console formatter ──────────────────────────────────────
147
+ # Pretty console output for development.
148
+ # Colors are disabled automatically outside development.
149
+ console_formatter = structlog.stdlib.ProcessorFormatter(
150
+ processor=structlog.dev.ConsoleRenderer(
151
+ colors=settings.is_development,
152
+ exception_formatter=structlog.dev.plain_traceback,
153
+ ),
154
+ foreign_pre_chain=shared_processors,
155
+ )
156
+
157
+ # ── JSON file formatter ────────────────────────────────────
158
+ # Structured JSON output for log aggregators and production
159
+ # log analysis.
160
+ json_formatter = structlog.stdlib.ProcessorFormatter(
161
+ processor=structlog.processors.JSONRenderer(),
162
+ foreign_pre_chain=shared_processors,
163
+ )
164
+
165
+ # ── Configure Structlog ────────────────────────────────────
166
+
167
+ structlog.configure(
168
+ processors=common_shared_processors
169
+ + [
170
+ structlog.stdlib.filter_by_level,
171
+ structlog.stdlib.PositionalArgumentsFormatter(),
172
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
173
+ ],
174
+ wrapper_class=structlog.stdlib.BoundLogger,
175
+ context_class=dict,
176
+ logger_factory=structlog.stdlib.LoggerFactory(),
177
+ cache_logger_on_first_use=True,
178
+ )
179
+
180
+ # ── Root logger ────────────────────────────────────────────
181
+
182
+ root = logging.getLogger()
183
+ root.setLevel(log_level)
184
+
185
+ # Remove existing handlers to avoid:
186
+ for handler in root.handlers[:]:
187
+ root.removeHandler(handler)
188
+ handler.close()
189
+
190
+ # ── Console handler ────────────────────────────────────────
191
+
192
+ console_handler = logging.StreamHandler(sys.stdout)
193
+ console_handler.setLevel(log_level)
194
+ console_handler.setFormatter(console_formatter)
195
+
196
+ root.addHandler(console_handler)
197
+
198
+ # ── File handlers ──────────────────────────────────────────
199
+ if settings.log_to_files:
200
+ _setup_file_logging(
201
+ log_dir=settings.log_dir,
202
+ root_level=log_level,
203
+ formatter=json_formatter,
204
+ )
205
+
206
+ # ── Third-party libraries ──────────────────────────────────
207
+
208
+ # FastAPI application logs.
209
+ logging.getLogger("fastapi").setLevel(log_level)
210
+
211
+ # Uvicorn access logs
212
+ logging.getLogger("uvicorn.access").setLevel(logging.INFO)
213
+
214
+ # Uvicorn server errors.
215
+ logging.getLogger("uvicorn.error").setLevel(logging.ERROR)
216
+
217
+ # APScheduler:
218
+ logging.getLogger("apscheduler").setLevel(logging.WARNING)
219
+
220
+ # SQLAlchemy SQL logging:
221
+ #
222
+ # Keep disabled unless explicitly debugging SQL.
223
+ logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
224
+
225
+ # SQLAlchemy connection pool logging.
226
+ logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
227
+
228
+
229
+ # ── Logger factory ─────────────────────────────────────────────
230
+ def get_logger(name: str = __name__):
231
+ return structlog.get_logger(name)
@@ -0,0 +1,188 @@
1
+ import re
2
+ import time
3
+ import uuid
4
+ from typing import Callable
5
+
6
+ from fastapi import FastAPI, Request, Response
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
9
+ from slowapi import Limiter
10
+ from slowapi.util import get_remote_address
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+
13
+ from app.core.config import settings
14
+ from app.core.logging import get_logger, set_request_id
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ # ── Rate limiter (slowapi + Redis) ───────────────────────────────
19
+ is_redis_store = settings.use_redis and bool(settings.redis_url)
20
+ limiter = Limiter(
21
+ key_func=get_remote_address,
22
+ storage_uri=settings.redis_url if is_redis_store else None,
23
+ default_limits=[f"{settings.rate_limit_per_minute}/minute"],
24
+ )
25
+
26
+
27
+ # ── Request-ID / Correlation-ID middleware ───────────────────────
28
+ class RequestIDMiddleware(BaseHTTPMiddleware):
29
+ """
30
+ Attaches a unique X-Request-ID to every request.
31
+ Respects an existing header if the upstream proxy already set one.
32
+ """
33
+
34
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
35
+ supplied_id = request.headers.get("X-Request-ID") or request.headers.get(
36
+ "X-Correlation-ID"
37
+ )
38
+ request_id = (
39
+ supplied_id
40
+ if supplied_id and re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", supplied_id)
41
+ else str(uuid.uuid4())
42
+ )
43
+ request.state.request_id = request_id
44
+ set_request_id(request_id)
45
+
46
+ response = await call_next(request)
47
+ response.headers["X-Request-ID"] = request_id
48
+ return response
49
+
50
+
51
+ # ── Structured request / response logging middleware ─────────────
52
+ class RequestLoggingMiddleware(BaseHTTPMiddleware):
53
+ """
54
+ Logs every HTTP request and response with timing, status, and IDs.
55
+ Skips health-check and metrics endpoints to avoid log noise.
56
+ """
57
+
58
+ SKIP_PATHS = {"/health", "/metrics", "/favicon.ico"}
59
+
60
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
61
+ if request.url.path in self.SKIP_PATHS:
62
+ return await call_next(request)
63
+
64
+ start = time.perf_counter()
65
+ request_id = getattr(request.state, "request_id", "")
66
+
67
+ # ── Log incoming request ──────────────────────────────
68
+ logger.info(
69
+ "Incoming request",
70
+ method=request.method,
71
+ path=request.url.path,
72
+ client_ip=request.client.host if request.client else "unknown",
73
+ user_agent=request.headers.get("user-agent", ""),
74
+ request_id=request_id,
75
+ )
76
+
77
+ response: Response = await call_next(request)
78
+
79
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
80
+
81
+ # ── Log outgoing response ─────────────────────────────
82
+ log_fn = logger.error if response.status_code >= 400 else logger.info
83
+ log_fn(
84
+ "Request completed",
85
+ method=request.method,
86
+ path=request.url.path,
87
+ status_code=response.status_code,
88
+ duration_ms=elapsed_ms,
89
+ request_id=request_id,
90
+ )
91
+
92
+ response.headers["X-Process-Time-Ms"] = str(elapsed_ms)
93
+ return response
94
+
95
+
96
+ # ── Security headers middleware ──────────────────────────────────
97
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
98
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
99
+ response = await call_next(request)
100
+ response.headers["X-Content-Type-Options"] = "nosniff"
101
+ response.headers["X-Frame-Options"] = "DENY"
102
+ response.headers["X-XSS-Protection"] = "1; mode=block"
103
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
104
+ if settings.is_production:
105
+ response.headers["Strict-Transport-Security"] = (
106
+ "max-age=31536000; includeSubDomains"
107
+ )
108
+ return response
109
+
110
+
111
+ class AuditMiddleware(BaseHTTPMiddleware):
112
+ """Persist an audit entry for every successful state-changing API request."""
113
+
114
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
115
+ response = await call_next(request)
116
+ if (
117
+ request.method not in {"POST", "PUT", "PATCH", "DELETE"}
118
+ or not request.url.path.startswith("/api/")
119
+ or response.status_code >= 400
120
+ ):
121
+ return response
122
+
123
+ user_id = None
124
+ authorization = request.headers.get("authorization", "")
125
+ if authorization.lower().startswith("bearer "):
126
+ try:
127
+ from app.core.security import decode_token
128
+
129
+ user_id = decode_token(
130
+ authorization.split(" ", 1)[1], expected_type="access"
131
+ ).get("sub")
132
+ except Exception:
133
+ user_id = None
134
+
135
+ try:
136
+ from app.db.session import AsyncSessionLocal
137
+ from app.services.audit import AuditService
138
+
139
+ async with AsyncSessionLocal() as session:
140
+ await AuditService(session).log(
141
+ action=request.method.lower(),
142
+ resource=request.url.path,
143
+ user_id=user_id,
144
+ metadata={"status_code": response.status_code},
145
+ ip_address=request.client.host if request.client else None,
146
+ user_agent=request.headers.get("user-agent"),
147
+ request_id=getattr(request.state, "request_id", None),
148
+ )
149
+ await session.commit()
150
+ except Exception as exc:
151
+ logger.error("Request audit logging failed", error=str(exc))
152
+
153
+ return response
154
+
155
+
156
+ # ── Registration helper ──────────────────────────────────────────
157
+ def register_middleware(app: FastAPI) -> None:
158
+ # Order matters: outermost middleware runs first on request, last on response.
159
+
160
+ # CORS (must be early so preflight OPTIONS pass)
161
+ app.add_middleware(
162
+ CORSMiddleware,
163
+ allow_origins=settings.ALLOWED_ORIGINS,
164
+ allow_credentials=True,
165
+ allow_methods=["*"],
166
+ allow_headers=["*"],
167
+ expose_headers=["X-Request-ID", "X-Process-Time-Ms"],
168
+ )
169
+
170
+ # Trusted hosts
171
+ if settings.is_production:
172
+ app.add_middleware(
173
+ TrustedHostMiddleware,
174
+ allowed_hosts=settings.ALLOWED_HOSTS,
175
+ )
176
+
177
+ # Security headers
178
+ app.add_middleware(SecurityHeadersMiddleware)
179
+
180
+ # Record successful state-changing API requests.
181
+ app.add_middleware(AuditMiddleware)
182
+
183
+ # Starlette runs the last added middleware first on incoming requests.
184
+ # Logging is added before Request-ID so it runs after the ID is set.
185
+ app.add_middleware(RequestLoggingMiddleware)
186
+
187
+ # Request-ID runs before logging so the logger can read the ID.
188
+ app.add_middleware(RequestIDMiddleware)
@@ -0,0 +1,108 @@
1
+ from typing import Any, Dict, Generic, List, Optional, TypeVar
2
+ from datetime import datetime, timezone
3
+
4
+ from app.core.logging import get_request_id
5
+ from app.utils.casing import CamelModel, keys_to_camel
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ # ── Pagination metadata ──────────────────────────────────────────
11
+ class PaginationMeta(CamelModel):
12
+ page: int
13
+ page_size: int
14
+ total_items: int
15
+ total_pages: int
16
+ has_next: bool
17
+ has_prev: bool
18
+
19
+
20
+ # ── Standard envelope models ─────────────────────────────────────
21
+ class APIResponse(CamelModel, Generic[T]):
22
+ success: bool
23
+ message: str
24
+ data: Optional[T] = None
25
+ meta: Optional[Dict[str, Any]] = None
26
+ error_code: Optional[str] = None
27
+ details: Any = None
28
+ timestamp: str = ""
29
+ request_id: Optional[str] = None
30
+
31
+
32
+ class PaginatedResponse(CamelModel, Generic[T]):
33
+ success: bool
34
+ message: str
35
+ data: List[T]
36
+ pagination: PaginationMeta
37
+ meta: Optional[Dict[str, Any]] = None
38
+ timestamp: str = ""
39
+ request_id: Optional[str] = None
40
+
41
+
42
+ # ── Builder helpers ──────────────────────────────────────────────
43
+ def _now() -> str:
44
+ return datetime.now(timezone.utc).isoformat()
45
+
46
+
47
+ def _request_id_or_current(request_id: Optional[str]) -> str:
48
+ return request_id or get_request_id()
49
+
50
+
51
+ def success_response(
52
+ data: Any = None,
53
+ message: str = "Success",
54
+ meta: Optional[Dict[str, Any]] = None,
55
+ request_id: Optional[str] = None,
56
+ ) -> APIResponse:
57
+ return APIResponse(
58
+ success=True,
59
+ message=message,
60
+ data=keys_to_camel(data),
61
+ meta=keys_to_camel(meta),
62
+ timestamp=_now(),
63
+ request_id=_request_id_or_current(request_id),
64
+ )
65
+
66
+
67
+ def error_response(
68
+ message: str = "An error occurred",
69
+ error_code: str = "ERROR",
70
+ details: Any = None,
71
+ request_id: Optional[str] = None,
72
+ ) -> APIResponse:
73
+ return APIResponse(
74
+ success=False,
75
+ message=message,
76
+ error_code=error_code,
77
+ details=keys_to_camel(details),
78
+ timestamp=_now(),
79
+ request_id=_request_id_or_current(request_id),
80
+ )
81
+
82
+
83
+ def paginated_response(
84
+ data: List[Any],
85
+ total_items: int,
86
+ page: int,
87
+ page_size: int,
88
+ message: str = "Success",
89
+ meta: Optional[Dict[str, Any]] = None,
90
+ request_id: Optional[str] = None,
91
+ ) -> PaginatedResponse:
92
+ total_pages = (total_items + page_size - 1) // page_size if page_size > 0 else 0
93
+ return PaginatedResponse(
94
+ success=True,
95
+ message=message,
96
+ data=keys_to_camel(data),
97
+ pagination=PaginationMeta(
98
+ page=page,
99
+ page_size=page_size,
100
+ total_items=total_items,
101
+ total_pages=total_pages,
102
+ has_next=page < total_pages,
103
+ has_prev=page > 1,
104
+ ),
105
+ meta=keys_to_camel(meta),
106
+ timestamp=_now(),
107
+ request_id=_request_id_or_current(request_id),
108
+ )