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,92 @@
1
+ from uuid import UUID
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+
5
+ from app.api.v1.users.repository import UserRepository
6
+ from app.api.v1.users.schema import UserCreate, UserResponse, UserUpdate
7
+ from app.core.exceptions import ConflictException, NotFoundException
8
+ from app.core.security import hash_password
9
+ from app.db.models import User
10
+
11
+
12
+ class UserService:
13
+ def __init__(self, session: AsyncSession):
14
+ self.repo = UserRepository(session)
15
+
16
+ async def list_users(self, page, page_size, pagination=True, **kwargs):
17
+ users, total = await self.repo.get_all(
18
+ page, page_size, pagination, **kwargs
19
+ )
20
+ return [self._to_response(user) for user in users], total
21
+
22
+ async def list_deleted_users(self, page, page_size, pagination=True, **kwargs):
23
+ users, total = await self.repo.get_deleted(
24
+ page=page,
25
+ page_size=page_size,
26
+ pagination=pagination,
27
+ search=kwargs.get("search"),
28
+ search_columns=[User.email, User.username, User.full_name],
29
+ sort_by=kwargs.get("sort_by"),
30
+ sort_order=kwargs.get("sort_order"),
31
+ )
32
+ return [self._to_response(user) for user in users], total
33
+
34
+ async def get_user(self, user_id: UUID) -> UserResponse:
35
+ user = await self.repo.get_active(user_id)
36
+ if not user:
37
+ raise NotFoundException("User", user_id)
38
+ return self._to_response(user)
39
+
40
+ async def create_user(self, data: UserCreate) -> UserResponse:
41
+ email, username = data.email.lower(), data.username.lower()
42
+ if await self.repo.exists("email", email):
43
+ raise ConflictException("An account with this email already exists")
44
+ if await self.repo.exists("username", username):
45
+ raise ConflictException("Username is already taken")
46
+ values = data.model_dump(exclude={"password"})
47
+ values.update(
48
+ email=email,
49
+ username=username,
50
+ hashed_password=hash_password(data.password),
51
+ )
52
+ return self._to_response(await self.repo.create(values))
53
+
54
+ async def update_user(self, user_id: UUID, data: UserUpdate) -> UserResponse:
55
+ user = await self.repo.get_by_id(user_id)
56
+ if not user:
57
+ raise NotFoundException("User", user_id)
58
+ values = data.model_dump(exclude_unset=True)
59
+ for field in ("email", "username"):
60
+ if field in values:
61
+ values[field] = values[field].lower()
62
+ if await self.repo.exists_for_other_user(field, values[field], user_id):
63
+ raise ConflictException(f"{field.title()} is already taken")
64
+ return self._to_response(await self.repo.update(user, values))
65
+
66
+ async def soft_delete_user(self, user_id: UUID, note: str) -> None:
67
+ user = await self.repo.get_by_id(user_id)
68
+ if not user:
69
+ raise NotFoundException("User", user_id)
70
+ await self.repo.soft_delete(user, note)
71
+
72
+ async def hard_delete_user(self, user_id: UUID, note: str) -> None:
73
+ user = await self.repo.get_by_id(user_id, include_deleted=True)
74
+ if not user:
75
+ raise NotFoundException("User", user_id)
76
+ await self.repo.hard_delete(user, note)
77
+
78
+ @staticmethod
79
+ def _to_response(user: User) -> UserResponse:
80
+ return UserResponse(
81
+ id=str(user.id),
82
+ email=user.email,
83
+ username=user.username,
84
+ full_name=user.full_name,
85
+ is_active=user.is_active,
86
+ is_superuser=user.is_superuser,
87
+ is_verified=user.is_verified,
88
+ last_login_at=user.last_login_at.isoformat() if user.last_login_at else None,
89
+ is_deleted=user.is_deleted,
90
+ deleted_at=user.deleted_at.isoformat() if user.deleted_at else None,
91
+ deletion_note=user.deletion_note,
92
+ )
@@ -0,0 +1,131 @@
1
+ import json
2
+ from functools import lru_cache
3
+ from typing import List
4
+
5
+ from pydantic import field_validator, model_validator
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+ from sqlalchemy.engine import URL
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ APP_NAME: str = "__PROJECT_NAME__"
12
+ APP_VERSION: str = "1.0.0"
13
+ APP_ENV: str = "development"
14
+ APP_DEBUG: bool = False
15
+ SECRET_KEY: str = "change-me-in-production"
16
+ ALLOWED_HOSTS: List[str] = ["*"]
17
+ ALLOWED_ORIGINS: List[str] = ["*"]
18
+ log_dir: str = "logs"
19
+ log_to_files: bool = False
20
+
21
+ pg_host: str = "localhost"
22
+ pg_port: int = 5432
23
+ pg_database: str = "project_name"
24
+ pg_user: str = "postgres"
25
+ pg_password: str = "postgres"
26
+ pg_sslmode: str = "prefer"
27
+ database_pool_size: int = 10
28
+ database_max_overflow: int = 20
29
+ database_pool_timeout: int = 30
30
+ database_debug: bool = False
31
+
32
+ access_token_expire_minutes: int = 30
33
+ refresh_token_expire_days: int = 7
34
+ algorithm: str = "HS256"
35
+ password_reset_token_expire_minutes: int = 15
36
+
37
+ use_redis: bool = False
38
+ redis_url: str = ""
39
+ rate_limit_per_minute: int = 60
40
+
41
+ mail_username: str = ""
42
+ mail_password: str = ""
43
+ mail_from: str = "noreply@example.com"
44
+ mail_port: int = 587
45
+ mail_server: str = "smtp.example.com"
46
+ mail_starttls: bool = True
47
+ mail_ssl_tls: bool = False
48
+ mail_from_name: str = "__PROJECT_NAME__"
49
+ frontend_url: str = "http://localhost:3000"
50
+
51
+ default_page_size: int = 20
52
+ max_page_size: int = 100
53
+
54
+ model_config = SettingsConfigDict(
55
+ env_file=".env",
56
+ env_file_encoding="utf-8",
57
+ case_sensitive=False,
58
+ extra="ignore",
59
+ )
60
+
61
+ @field_validator("ALLOWED_HOSTS", "ALLOWED_ORIGINS", mode="before")
62
+ @classmethod
63
+ def parse_json_or_csv_list(cls, value):
64
+ if isinstance(value, str):
65
+ value = value.strip()
66
+ if not value:
67
+ return []
68
+ if value.startswith("["):
69
+ return json.loads(value)
70
+ return [item.strip() for item in value.split(",") if item.strip()]
71
+ return value
72
+
73
+ @model_validator(mode="after")
74
+ def validate_production_settings(self):
75
+ if not self.is_production:
76
+ return self
77
+ default_secrets = {
78
+ "change-me-in-production",
79
+ "replace-with-a-long-random-secret",
80
+ }
81
+ if self.SECRET_KEY in default_secrets or len(self.SECRET_KEY) < 32:
82
+ raise ValueError(
83
+ "Production SECRET_KEY must be a unique value of at least 32 characters"
84
+ )
85
+ if not self.ALLOWED_HOSTS or "*" in self.ALLOWED_HOSTS:
86
+ raise ValueError("Production ALLOWED_HOSTS must contain explicit hostnames")
87
+ if not self.ALLOWED_ORIGINS or "*" in self.ALLOWED_ORIGINS:
88
+ raise ValueError("Production ALLOWED_ORIGINS must contain explicit origins")
89
+ if self.pg_sslmode in {"disable", "allow", "prefer"}:
90
+ raise ValueError("Production PG_SSLMODE must verify or require TLS")
91
+ return self
92
+
93
+ @property
94
+ def postgres_url(self) -> str:
95
+ return URL.create(
96
+ "postgresql+psycopg",
97
+ username=self.pg_user,
98
+ password=self.pg_password,
99
+ host=self.pg_host,
100
+ port=self.pg_port,
101
+ database=self.pg_database,
102
+ query={"sslmode": self.pg_sslmode},
103
+ ).render_as_string(hide_password=False)
104
+
105
+ @property
106
+ def postgres_async_url(self) -> str:
107
+ return URL.create(
108
+ "postgresql+asyncpg",
109
+ username=self.pg_user,
110
+ password=self.pg_password,
111
+ host=self.pg_host,
112
+ port=self.pg_port,
113
+ database=self.pg_database,
114
+ query={"ssl": self.pg_sslmode},
115
+ ).render_as_string(hide_password=False)
116
+
117
+ @property
118
+ def is_production(self) -> bool:
119
+ return self.APP_ENV.lower() in {"production", "prod"}
120
+
121
+ @property
122
+ def is_development(self) -> bool:
123
+ return self.APP_ENV.lower() in {"development", "dev"}
124
+
125
+
126
+ @lru_cache
127
+ def get_settings() -> Settings:
128
+ return Settings()
129
+
130
+
131
+ settings = get_settings()
@@ -0,0 +1,71 @@
1
+ from collections.abc import AsyncGenerator, Generator
2
+ from typing import Optional
3
+
4
+ from fastapi import Depends
5
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy.orm import Session
8
+
9
+ from app.api.v1.auth.repository import RevokedTokenRepository
10
+ from app.core.exceptions import ForbiddenException, UnauthorizedException
11
+ from app.core.security import decode_token
12
+ from app.db.session import AsyncSessionLocal, SyncSessionLocal
13
+
14
+ bearer_scheme = HTTPBearer(auto_error=False)
15
+
16
+
17
+ async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
18
+ async with AsyncSessionLocal() as session:
19
+ try:
20
+ yield session
21
+ await session.commit()
22
+ except Exception:
23
+ await session.rollback()
24
+ raise
25
+
26
+
27
+ def get_sync_db() -> Generator[Session, None, None]:
28
+ db = SyncSessionLocal()
29
+ try:
30
+ yield db
31
+ db.commit()
32
+ except Exception:
33
+ db.rollback()
34
+ raise
35
+ finally:
36
+ db.close()
37
+
38
+
39
+ def get_db() -> Generator[Session, None, None]:
40
+ yield from get_sync_db()
41
+
42
+
43
+ async def get_current_user_payload(
44
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
45
+ db: AsyncSession = Depends(get_async_db),
46
+ ) -> dict:
47
+ if not credentials:
48
+ raise UnauthorizedException("Bearer token missing")
49
+ payload = decode_token(credentials.credentials, expected_type="access")
50
+ jti = payload.get("jti")
51
+ if not jti or await RevokedTokenRepository(db).is_revoked(jti):
52
+ raise UnauthorizedException("Token is invalid or revoked")
53
+ return payload
54
+
55
+
56
+ async def get_current_user_id(
57
+ payload: dict = Depends(get_current_user_payload),
58
+ ) -> str:
59
+ user_id = payload.get("sub")
60
+ if not user_id:
61
+ raise UnauthorizedException("Invalid token payload")
62
+ return user_id
63
+
64
+
65
+ async def require_superuser(
66
+ payload: dict = Depends(get_current_user_payload),
67
+ ) -> dict:
68
+ if not payload.get("is_superuser"):
69
+ raise ForbiddenException("Superuser access required")
70
+ return payload
71
+
@@ -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)