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,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
+ )
@@ -0,0 +1,115 @@
1
+ import hashlib
2
+ import hmac
3
+ import secrets
4
+ import uuid
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Dict, Optional, Tuple
7
+
8
+ from jose import JWTError, jwt
9
+ from passlib.context import CryptContext
10
+
11
+ from app.core.config import settings
12
+ from app.core.exceptions import UnauthorizedException
13
+
14
+ # ── Password hashing (bcrypt, cost=12) ──────────────────────────
15
+ pwd_context = CryptContext(
16
+ schemes=["bcrypt"],
17
+ deprecated="auto",
18
+ bcrypt__rounds=12, # work factor — increase for more security
19
+ )
20
+
21
+
22
+ def hash_password(plain: str) -> str:
23
+ """Hash a plain-text password using bcrypt."""
24
+ return pwd_context.hash(plain)
25
+
26
+
27
+ def verify_password(plain: str, hashed: str) -> bool:
28
+ """Verify a plain-text password against its bcrypt hash."""
29
+ return pwd_context.verify(plain, hashed)
30
+
31
+
32
+ # ── JWT helpers ──────────────────────────────────────────────────
33
+ def _utcnow() -> datetime:
34
+ return datetime.now(timezone.utc)
35
+
36
+
37
+ def create_access_token(
38
+ subject: Any,
39
+ extra_claims: Optional[Dict] = None,
40
+ ) -> Tuple[str, datetime]:
41
+ expire = _utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
42
+ payload = {
43
+ "sub": str(subject),
44
+ "exp": expire,
45
+ "iat": _utcnow(),
46
+ "jti": str(uuid.uuid4()),
47
+ "type": "access",
48
+ **(extra_claims or {}),
49
+ }
50
+ token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
51
+ return token, expire
52
+
53
+
54
+ def create_refresh_token(subject: Any) -> Tuple[str, datetime]:
55
+ expire = _utcnow() + timedelta(days=settings.refresh_token_expire_days)
56
+ payload = {
57
+ "sub": str(subject),
58
+ "exp": expire,
59
+ "iat": _utcnow(),
60
+ "jti": str(uuid.uuid4()),
61
+ "type": "refresh",
62
+ }
63
+ token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
64
+ return token, expire
65
+
66
+
67
+ def create_password_reset_token(email: str) -> str:
68
+ expire = _utcnow() + timedelta(minutes=settings.password_reset_token_expire_minutes)
69
+ payload = {"sub": email, "exp": expire, "type": "password_reset"}
70
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
71
+
72
+
73
+ def decode_token(token: str, expected_type: str = "access") -> Dict:
74
+ """
75
+ Decode and validate a JWT. Raises UnauthorizedException on failure.
76
+ """
77
+ try:
78
+ payload = jwt.decode(
79
+ token, settings.SECRET_KEY, algorithms=[settings.algorithm]
80
+ )
81
+ except JWTError as exc:
82
+ raise UnauthorizedException(f"Invalid or expired token: {exc}") from exc
83
+
84
+ if payload.get("type") != expected_type:
85
+ raise UnauthorizedException(f"Expected token type '{expected_type}'")
86
+
87
+ return payload
88
+
89
+
90
+ # ── Webhook signature verification ──────────────────────────────
91
+ def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
92
+ """
93
+ Verify an HMAC-SHA256 webhook signature.
94
+ Expected header format: 'sha256=<hex_digest>'
95
+ """
96
+ expected = (
97
+ "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
98
+ )
99
+ return hmac.compare_digest(expected, signature)
100
+
101
+
102
+ # ── Secure random helpers ────────────────────────────────────────
103
+ def generate_secure_token(nbytes: int = 32) -> str:
104
+ """Generate a URL-safe random token (for email verification, API keys, etc.)."""
105
+ return secrets.token_urlsafe(nbytes)
106
+
107
+
108
+ def hash_opaque_token(token: str) -> str:
109
+ """Return a one-way digest suitable for storing an opaque token."""
110
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
111
+
112
+
113
+ def generate_api_key() -> str:
114
+ """Generate a prefixed API key."""
115
+ return f"sk_{secrets.token_urlsafe(40)}"
@@ -0,0 +1 @@
1
+ """Database package."""
@@ -0,0 +1,5 @@
1
+ from sqlalchemy.orm import DeclarativeBase
2
+
3
+
4
+ class Base(DeclarativeBase):
5
+ pass
@@ -0,0 +1,10 @@
1
+ from app.db.models.user import User
2
+ from app.db.models.audit_log import AuditLog
3
+ from app.db.models.auth_token import (
4
+ EmailVerificationToken,
5
+ PasswordResetToken,
6
+ UserSession,
7
+ )
8
+ from app.db.models.notification import Notification
9
+ from app.db.models.revoked_token import RevokedToken
10
+
@@ -0,0 +1,58 @@
1
+ from datetime import datetime
2
+ import uuid
3
+
4
+ from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
5
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
6
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
+
8
+ from app.db.base import Base
9
+ from app.db.schemas.names import APP_SCHEMA
10
+
11
+
12
+ class AuditLog(Base):
13
+ __tablename__ = "audit_logs"
14
+
15
+ id: Mapped[uuid.UUID] = mapped_column(
16
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
17
+ )
18
+ created_at: Mapped[datetime] = mapped_column(
19
+ DateTime(timezone=True), server_default=func.now(), nullable=False
20
+ )
21
+
22
+ # ── Who ───────────────────────────────────────────────────
23
+ user_id: Mapped[uuid.UUID | None] = mapped_column(
24
+ UUID(as_uuid=True),
25
+ ForeignKey("auth.users.id", ondelete="SET NULL"),
26
+ nullable=True,
27
+ index=True,
28
+ )
29
+ user_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
30
+
31
+ # ── What ──────────────────────────────────────────────────
32
+ action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
33
+ resource: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
34
+ resource_id: Mapped[str | None] = mapped_column(
35
+ String(255), nullable=True, index=True
36
+ )
37
+
38
+ # ── Payload ───────────────────────────────────────────────
39
+ old_values: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
40
+ new_values: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
41
+ log_metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
42
+
43
+ # ── Context ───────────────────────────────────────────────
44
+ ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
45
+ user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
46
+ request_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
47
+
48
+ # ── Relationship ──────────────────────────────────────────
49
+ user: Mapped["User"] = relationship("User", back_populates="audit_logs")
50
+
51
+ __table_args__ = (
52
+ Index("ix_audit_resource_action", "resource", "action"),
53
+ Index("ix_audit_user_resource", "user_id", "resource"),
54
+ {"schema": APP_SCHEMA},
55
+ )
56
+
57
+ def __repr__(self) -> str:
58
+ return f"<AuditLog action={self.action} resource={self.resource} id={self.resource_id}>"
@@ -0,0 +1,72 @@
1
+ from datetime import datetime
2
+ import uuid
3
+
4
+ from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
5
+ from sqlalchemy.dialects.postgresql import UUID
6
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
+
8
+ from app.db.base import Base
9
+ from app.db.schemas.names import AUTH_SCHEMA
10
+
11
+
12
+ class EmailVerificationToken(Base):
13
+ __tablename__ = "email_verification_tokens"
14
+ __table_args__ = {"schema": AUTH_SCHEMA}
15
+
16
+ id: Mapped[uuid.UUID] = mapped_column(
17
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
18
+ )
19
+ user_id: Mapped[uuid.UUID] = mapped_column(
20
+ UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
21
+ )
22
+ token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
23
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
24
+ used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
25
+ created_at: Mapped[datetime] = mapped_column(
26
+ DateTime(timezone=True), server_default=func.now(), nullable=False
27
+ )
28
+
29
+
30
+ class PasswordResetToken(Base):
31
+ __tablename__ = "password_reset_tokens"
32
+ __table_args__ = {"schema": AUTH_SCHEMA}
33
+
34
+ id: Mapped[uuid.UUID] = mapped_column(
35
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
36
+ )
37
+ user_id: Mapped[uuid.UUID] = mapped_column(
38
+ UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
39
+ )
40
+ token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
41
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
42
+ used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
43
+ created_at: Mapped[datetime] = mapped_column(
44
+ DateTime(timezone=True), server_default=func.now(), nullable=False
45
+ )
46
+
47
+
48
+ class UserSession(Base):
49
+ __tablename__ = "user_sessions"
50
+ __table_args__ = (
51
+ Index("ix_user_sessions_user_active", "user_id", "revoked_at"),
52
+ {"schema": AUTH_SCHEMA},
53
+ )
54
+
55
+ id: Mapped[uuid.UUID] = mapped_column(
56
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
57
+ )
58
+ user_id: Mapped[uuid.UUID] = mapped_column(
59
+ UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
60
+ )
61
+ refresh_jti: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
62
+ previous_refresh_jti: Mapped[str | None] = mapped_column(String(64), index=True)
63
+ user_agent: Mapped[str | None] = mapped_column(Text)
64
+ ip_address: Mapped[str | None] = mapped_column(String(45))
65
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
66
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
67
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
68
+ created_at: Mapped[datetime] = mapped_column(
69
+ DateTime(timezone=True), server_default=func.now(), nullable=False
70
+ )
71
+
72
+ user = relationship("User", back_populates="sessions")
@@ -0,0 +1,49 @@
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
5
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
6
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
+
8
+ from app.db.base import Base
9
+ from app.db.schemas.common import BaseModelMixin
10
+ from app.db.schemas.names import APP_SCHEMA
11
+
12
+
13
+ class Notification(BaseModelMixin, Base):
14
+ __tablename__ = "notifications"
15
+ __table_args__ = {"schema": APP_SCHEMA}
16
+
17
+ user_id: Mapped[uuid.UUID] = mapped_column(
18
+ UUID(as_uuid=True),
19
+ ForeignKey("auth.users.id", ondelete="CASCADE"),
20
+ nullable=False,
21
+ index=True,
22
+ )
23
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
24
+ body: Mapped[str] = mapped_column(Text, nullable=False)
25
+ notification_type: Mapped[str] = mapped_column(
26
+ String(100), nullable=False, index=True
27
+ ) # e.g. "info", "warning", "success", "error", "system"
28
+
29
+ # Channel: "in_app" | "email" | "push" | "webhook"
30
+ channel: Mapped[str] = mapped_column(String(50), default="in_app", nullable=False)
31
+
32
+ is_read: Mapped[bool] = mapped_column(
33
+ Boolean, default=False, nullable=False, index=True
34
+ )
35
+ read_at: Mapped[datetime | None] = mapped_column(
36
+ DateTime(timezone=True), nullable=True
37
+ )
38
+
39
+ # Arbitrary extra payload (deep link, action URL, entity ref, etc.)
40
+ payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
41
+
42
+ user: Mapped["User"] = relationship("User", back_populates="notifications")
43
+
44
+ def mark_read(self) -> None:
45
+ self.is_read = True
46
+ self.read_at = datetime.now(timezone.utc)
47
+
48
+ def __repr__(self) -> str:
49
+ return f"<Notification user={self.user_id} type={self.notification_type} read={self.is_read}>"
@@ -0,0 +1,21 @@
1
+ from datetime import datetime
2
+
3
+ from sqlalchemy import DateTime, String
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+
6
+ from app.db.base import Base
7
+ from app.db.schemas.common import BaseModelMixin
8
+ from app.db.schemas.names import AUTH_SCHEMA
9
+
10
+
11
+ class RevokedToken(BaseModelMixin, Base):
12
+ __tablename__ = "revoked_tokens"
13
+ __table_args__ = {"schema": AUTH_SCHEMA}
14
+
15
+ jti: Mapped[str] = mapped_column(
16
+ String(64), unique=True, nullable=False, index=True
17
+ )
18
+ token_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
19
+ expires_at: Mapped[datetime] = mapped_column(
20
+ DateTime(timezone=True), nullable=False
21
+ )
@@ -0,0 +1,33 @@
1
+ from datetime import datetime
2
+
3
+ from sqlalchemy import Boolean, DateTime, String
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.db.base import Base
7
+ from app.db.schemas.common import BaseModelMixin
8
+ from app.db.schemas.names import AUTH_SCHEMA
9
+
10
+
11
+ class User(BaseModelMixin, Base):
12
+ __tablename__ = "users"
13
+ __table_args__ = {"schema": AUTH_SCHEMA}
14
+
15
+ email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
16
+ username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
17
+ full_name: Mapped[str] = mapped_column(String(255), nullable=False)
18
+ hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
19
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
20
+ is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
21
+ is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
22
+ last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
23
+
24
+ notifications: Mapped[list["Notification"]] = relationship(
25
+ "Notification", back_populates="user", lazy="dynamic"
26
+ )
27
+ audit_logs: Mapped[list["AuditLog"]] = relationship(
28
+ "AuditLog", back_populates="user", lazy="dynamic"
29
+ )
30
+ sessions: Mapped[list["UserSession"]] = relationship(
31
+ "UserSession", back_populates="user", lazy="dynamic"
32
+ )
33
+
@@ -0,0 +1,8 @@
1
+ from app.db.schemas.common import BaseModelMixin, SoftDeleteMixin, TimestampMixin, UUIDMixin
2
+
3
+ __all__ = [
4
+ "BaseModelMixin",
5
+ "SoftDeleteMixin",
6
+ "TimestampMixin",
7
+ "UUIDMixin",
8
+ ]