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,86 @@
1
+ from sqlalchemy import create_engine, text
2
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
3
+ from sqlalchemy.orm import Session, sessionmaker
4
+
5
+ from app.core.config import settings
6
+ from app.core.logging import get_logger
7
+ from app.db.base import Base
8
+ from app.db.models import *
9
+ from app.db.schemas.names import ALL_SCHEMAS
10
+
11
+ logger = get_logger(__name__)
12
+
13
+ async_engine = create_async_engine(
14
+ settings.postgres_async_url,
15
+ echo=settings.database_debug,
16
+ pool_size=settings.database_pool_size,
17
+ max_overflow=settings.database_max_overflow,
18
+ pool_timeout=settings.database_pool_timeout,
19
+ pool_pre_ping=True,
20
+ )
21
+
22
+ AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
23
+ bind=async_engine,
24
+ class_=AsyncSession,
25
+ autoflush=False,
26
+ autocommit=False,
27
+ expire_on_commit=False,
28
+ )
29
+
30
+ sync_engine = create_engine(
31
+ settings.postgres_url,
32
+ echo=settings.database_debug,
33
+ pool_size=settings.database_pool_size,
34
+ max_overflow=settings.database_max_overflow,
35
+ pool_timeout=settings.database_pool_timeout,
36
+ pool_pre_ping=True,
37
+ )
38
+
39
+ SyncSessionLocal: sessionmaker[Session] = sessionmaker(
40
+ bind=sync_engine,
41
+ class_=Session,
42
+ autoflush=False,
43
+ autocommit=False,
44
+ expire_on_commit=False,
45
+ )
46
+
47
+ engine = sync_engine
48
+ SessionLocal = SyncSessionLocal
49
+
50
+
51
+ def create_db_and_tables() -> None:
52
+ with sync_engine.begin() as connection:
53
+ for schema in ALL_SCHEMAS:
54
+ connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema}"))
55
+ Base.metadata.create_all(bind=connection)
56
+
57
+
58
+ def get_session():
59
+ db = SessionLocal()
60
+ try:
61
+ yield db
62
+ finally:
63
+ db.close()
64
+
65
+
66
+ async def check_db_health() -> dict:
67
+ try:
68
+ async with AsyncSessionLocal() as session:
69
+ await session.execute(text("SELECT 1"))
70
+ return {"status": "healthy", "database": "connected"}
71
+ except Exception as exc:
72
+ logger.error("Database health check failed", error=str(exc))
73
+ return {"status": "unhealthy", "database": "disconnected", "error": str(exc)}
74
+
75
+
76
+ async def connect_db() -> None:
77
+ result = await check_db_health()
78
+ if result["status"] != "healthy":
79
+ raise RuntimeError("Database connection failed on startup")
80
+ logger.info("Database connected successfully")
81
+
82
+
83
+ async def disconnect_db() -> None:
84
+ await async_engine.dispose()
85
+ sync_engine.dispose()
86
+ logger.info("Database connections closed")
@@ -0,0 +1,44 @@
1
+ from fastapi import Query
2
+
3
+ from app.core.config import settings
4
+
5
+
6
+ def get_pagination_offset(page: int, page_size: int) -> int:
7
+ return (page - 1) * page_size
8
+
9
+
10
+ def apply_pagination(query, page: int, page_size: int, enabled: bool = True):
11
+ if not enabled:
12
+ return query
13
+ return query.offset(get_pagination_offset(page, page_size)).limit(page_size)
14
+
15
+
16
+ class PaginationParams:
17
+ def __init__(
18
+ self,
19
+ page: int = Query(default=1, ge=1),
20
+ page_size: int = Query(
21
+ default=settings.default_page_size,
22
+ ge=1,
23
+ le=settings.max_page_size,
24
+ alias="pageSize",
25
+ ),
26
+ pagination: bool = Query(default=True),
27
+ search: str | None = Query(default=None),
28
+ sort_by: str | None = Query(default=None, alias="sortBy"),
29
+ sort_order: str | None = Query(default="asc", alias="sortOrder"),
30
+ ):
31
+ self.page = page
32
+ self.page_size = page_size
33
+ self.pagination = pagination
34
+ self.search = search
35
+ self.sort_by = sort_by
36
+ self.sort_order = sort_order
37
+
38
+ @property
39
+ def offset(self) -> int:
40
+ return get_pagination_offset(self.page, self.page_size)
41
+
42
+ @property
43
+ def limit(self) -> int:
44
+ return self.page_size
@@ -0,0 +1,51 @@
1
+ from sqlalchemy import Text, and_, or_
2
+ from sqlalchemy.sql.sqltypes import String
3
+
4
+
5
+ DEFAULT_EXCLUDED_FIELDS = {
6
+ "hashed_password",
7
+ "request_sql",
8
+ "request_params_json",
9
+ "response_json",
10
+ "raw_response_json",
11
+ "coverages_json",
12
+ }
13
+
14
+
15
+ def text_search_filter(model, search: str | None, columns=None, name_columns=None):
16
+ query = (search or "").strip()
17
+ if not query:
18
+ return None
19
+
20
+ fields = columns or [
21
+ column
22
+ for column in model.__table__.columns
23
+ if isinstance(column.type, (String, Text))
24
+ and column.key not in DEFAULT_EXCLUDED_FIELDS
25
+ ]
26
+ expressions = []
27
+ if fields:
28
+ like = f"%{query}%"
29
+ expressions.extend(field.ilike(like) for field in fields)
30
+
31
+ if name_columns:
32
+ first_name_column, last_name_column = name_columns
33
+ parts = query.split(maxsplit=1)
34
+ if len(parts) == 2:
35
+ expressions.extend(
36
+ [
37
+ and_(
38
+ first_name_column.ilike(f"%{parts[0]}%"),
39
+ last_name_column.ilike(f"%{parts[1]}%"),
40
+ ),
41
+ and_(
42
+ first_name_column.ilike(f"%{parts[1]}%"),
43
+ last_name_column.ilike(f"%{parts[0]}%"),
44
+ ),
45
+ ]
46
+ )
47
+
48
+ if not expressions:
49
+ return None
50
+
51
+ return or_(*expressions)
@@ -0,0 +1,77 @@
1
+ import re
2
+ from typing import Any
3
+
4
+ from fastapi import HTTPException
5
+ from sqlalchemy import inspect
6
+
7
+
8
+ def to_snake_case(value: str) -> str:
9
+ value = value.strip()
10
+ value = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", value)
11
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower()
12
+
13
+
14
+ def sort_expressions(
15
+ model: Any,
16
+ sort_by: str | None,
17
+ sort_order: str | None,
18
+ default_order: tuple[Any, ...] = (),
19
+ ) -> tuple[Any, ...]:
20
+ if not sort_by:
21
+ return default_order
22
+
23
+ field = to_snake_case(sort_by)
24
+ columns = inspect(model).columns
25
+ if field not in columns:
26
+ raise HTTPException(status_code=422, detail=f"Invalid sortBy '{sort_by}'")
27
+
28
+ column = columns[field]
29
+ direction = _normalize_sort_order(sort_order)
30
+ return (column.desc() if direction == "desc" else column.asc(),)
31
+
32
+
33
+ def sort_items(
34
+ items: list[Any], sort_by: str | None, sort_order: str | None
35
+ ) -> list[Any]:
36
+ if not sort_by:
37
+ return items
38
+
39
+ field = to_snake_case(sort_by)
40
+ direction = _normalize_sort_order(sort_order)
41
+
42
+ if items:
43
+ first = items[0]
44
+ if isinstance(first, dict):
45
+ if sort_by not in first and field not in first:
46
+ raise HTTPException(
47
+ status_code=422,
48
+ detail=f"Invalid sortBy '{sort_by}'",
49
+ )
50
+ elif not hasattr(first, field):
51
+ raise HTTPException(status_code=422, detail=f"Invalid sortBy '{sort_by}'")
52
+
53
+ return sorted(
54
+ items,
55
+ key=lambda item: _item_value(item, sort_by, field),
56
+ reverse=direction == "desc",
57
+ )
58
+
59
+
60
+ def _normalize_sort_order(sort_order: str | None) -> str:
61
+ value = (sort_order or "asc").lower()
62
+ if value not in {"asc", "desc"}:
63
+ raise HTTPException(
64
+ status_code=422,
65
+ detail="sortOrder must be either 'asc' or 'desc'",
66
+ )
67
+ return value
68
+
69
+
70
+ def _item_value(item, sort_by: str, field: str):
71
+ if isinstance(item, dict):
72
+ value = item.get(sort_by)
73
+ if value is None:
74
+ value = item.get(field)
75
+ else:
76
+ value = getattr(item, field, None)
77
+ return (value is None, value)
@@ -0,0 +1,66 @@
1
+ from contextlib import asynccontextmanager
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.responses import JSONResponse
5
+ from slowapi.errors import RateLimitExceeded
6
+
7
+ from app.api.v1.api import register_api_routes
8
+ from app.core.config import settings
9
+ from app.core.exceptions import register_exception_handlers
10
+ from app.core.logging import configure_logging, get_logger
11
+ from app.core.middleware import limiter, register_middleware
12
+ from app.core.responses import error_response
13
+ from app.db.session import connect_db, disconnect_db
14
+
15
+ configure_logging()
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ @asynccontextmanager
20
+ async def lifespan(app: FastAPI):
21
+ logger.info("Starting application", name=settings.APP_NAME, env=settings.APP_ENV)
22
+ await connect_db()
23
+ yield
24
+ await disconnect_db()
25
+ logger.info("Application shutdown complete")
26
+
27
+
28
+ app = FastAPI(
29
+ title=settings.APP_NAME,
30
+ version=settings.APP_VERSION,
31
+ description="FastAPI boilerplate with JWT authentication, RBAC, and audit logs.",
32
+ docs_url="/docs" if not settings.is_production else None,
33
+ redoc_url="/redoc" if not settings.is_production else None,
34
+ openapi_url="/openapi.json" if not settings.is_production else None,
35
+ lifespan=lifespan,
36
+ swagger_ui_parameters={"docExpansion": "none"},
37
+ )
38
+ app.state.limiter = limiter
39
+
40
+
41
+ @app.exception_handler(RateLimitExceeded)
42
+ async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
43
+ return JSONResponse(
44
+ status_code=429,
45
+ content=error_response(
46
+ message="Too many requests. Please slow down.",
47
+ error_code="RATE_LIMIT_EXCEEDED",
48
+ request_id=getattr(request.state, "request_id", None),
49
+ ).model_dump(by_alias=True),
50
+ )
51
+
52
+
53
+ register_middleware(app)
54
+ register_exception_handlers(app)
55
+
56
+
57
+ @app.get("/", tags=["Root"])
58
+ async def root():
59
+ return {
60
+ "message": f"Welcome to {settings.APP_NAME}",
61
+ "version": settings.APP_VERSION,
62
+ "docs": None if settings.is_production else "/docs",
63
+ }
64
+
65
+
66
+ register_api_routes(app)
@@ -0,0 +1,347 @@
1
+ from typing import Any, Dict, Generic, List, Optional, Tuple, Type, TypeVar
2
+ from uuid import UUID
3
+
4
+ from sqlalchemy import Select, func, select
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlalchemy.orm import Session
7
+
8
+ from app.db.base import Base
9
+ from app.helper.search import text_search_filter
10
+ from app.helper.pagination_helper import apply_pagination
11
+ from app.helper.sorting import sort_expressions
12
+
13
+ ModelT = TypeVar("ModelT", bound=Base)
14
+
15
+
16
+ class BaseRepository(Generic[ModelT]):
17
+ def __init__(self, model: Type[ModelT], session: AsyncSession):
18
+ self.model = model
19
+ self.session = session
20
+
21
+ def _active_query(self) -> Select:
22
+ q = select(self.model)
23
+ if hasattr(self.model, "is_deleted"):
24
+ q = q.where(self.model.is_deleted == False)
25
+ return q
26
+
27
+ async def create(self, data: Dict[str, Any]) -> ModelT:
28
+ instance = self.model(**data)
29
+ self.session.add(instance)
30
+ await self.session.flush()
31
+ await self.session.refresh(instance)
32
+ return instance
33
+
34
+ async def get_by_id(
35
+ self, id: UUID, include_deleted: bool = False
36
+ ) -> Optional[ModelT]:
37
+ q = select(self.model).where(self.model.id == id)
38
+ if not include_deleted and hasattr(self.model, "is_deleted"):
39
+ q = q.where(self.model.is_deleted == False)
40
+ result = await self.session.execute(q)
41
+ return result.scalar_one_or_none()
42
+
43
+ async def get_by_field(self, field: str, value: Any) -> Optional[ModelT]:
44
+ q = self._active_query().where(getattr(self.model, field) == value)
45
+ result = await self.session.execute(q)
46
+ return result.scalar_one_or_none()
47
+
48
+ async def get_all(
49
+ self,
50
+ page: int = 1,
51
+ page_size: int = 20,
52
+ pagination: bool = True,
53
+ filters: Optional[List] = None,
54
+ search: str | None = None,
55
+ search_columns: Optional[List] = None,
56
+ order_by=None,
57
+ sort_by: str | None = None,
58
+ sort_order: str | None = None,
59
+ ) -> Tuple[List[ModelT], int]:
60
+ q = self._active_query()
61
+ if filters:
62
+ q = q.where(*filters)
63
+ search_filter = text_search_filter(self.model, search, search_columns)
64
+ if search_filter is not None:
65
+ q = q.where(search_filter)
66
+ if isinstance(order_by, (list, tuple)):
67
+ default_order = tuple(order_by)
68
+ else:
69
+ default_order = (order_by,) if order_by is not None else ()
70
+ if not default_order and hasattr(self.model, "created_at"):
71
+ default_order = (self.model.created_at.desc(),)
72
+ order = sort_expressions(self.model, sort_by, sort_order, default_order)
73
+ if order:
74
+ q = q.order_by(*order)
75
+
76
+ count_q = select(func.count()).select_from(q.subquery())
77
+ total = (await self.session.execute(count_q)).scalar_one()
78
+
79
+ q = apply_pagination(q, page, page_size, pagination)
80
+
81
+ result = await self.session.execute(q)
82
+ return list(result.scalars().all()), total
83
+
84
+ async def get_deleted(
85
+ self,
86
+ page: int = 1,
87
+ page_size: int = 20,
88
+ pagination: bool = True,
89
+ filters: Optional[List] = None,
90
+ search: str | None = None,
91
+ search_columns: Optional[List] = None,
92
+ order_by=None,
93
+ sort_by: str | None = None,
94
+ sort_order: str | None = None,
95
+ ) -> Tuple[List[ModelT], int]:
96
+ if not hasattr(self.model, "is_deleted"):
97
+ return [], 0
98
+
99
+ q = select(self.model).where(self.model.is_deleted == True)
100
+ if filters:
101
+ q = q.where(*filters)
102
+ search_filter = text_search_filter(self.model, search, search_columns)
103
+ if search_filter is not None:
104
+ q = q.where(search_filter)
105
+ if isinstance(order_by, (list, tuple)):
106
+ default_order = tuple(order_by)
107
+ else:
108
+ default_order = (order_by,) if order_by is not None else ()
109
+ if not default_order and hasattr(self.model, "deleted_at"):
110
+ default_order = (self.model.deleted_at.desc(),)
111
+ order = sort_expressions(self.model, sort_by, sort_order, default_order)
112
+ if order:
113
+ q = q.order_by(*order)
114
+
115
+ count_q = select(func.count()).select_from(q.subquery())
116
+ total = (await self.session.execute(count_q)).scalar_one()
117
+
118
+ q = apply_pagination(q, page, page_size, pagination)
119
+
120
+ result = await self.session.execute(q)
121
+ return list(result.scalars().all()), total
122
+
123
+ async def update(self, instance: ModelT, data: Dict[str, Any]) -> ModelT:
124
+ for key, value in data.items():
125
+ if hasattr(instance, key):
126
+ setattr(instance, key, value)
127
+ self.session.add(instance)
128
+ await self.session.flush()
129
+ await self.session.refresh(instance)
130
+ return instance
131
+
132
+ async def soft_delete(self, instance: ModelT, note: str) -> ModelT:
133
+ if not hasattr(instance, "soft_delete"):
134
+ raise AttributeError(f"{self.model.__name__} does not support soft delete")
135
+ instance.soft_delete(note)
136
+ self.session.add(instance)
137
+ await self.session.flush()
138
+ return instance
139
+
140
+ async def restore(self, instance: ModelT) -> ModelT:
141
+ if not hasattr(instance, "restore"):
142
+ raise AttributeError(f"{self.model.__name__} does not support restore")
143
+ instance.restore()
144
+ self.session.add(instance)
145
+ await self.session.flush()
146
+ return instance
147
+
148
+ async def hard_delete(self, instance: ModelT, note: str) -> None:
149
+ if hasattr(instance, "deletion_note"):
150
+ instance.deletion_note = note
151
+ self.session.add(instance)
152
+ await self.session.flush()
153
+ await self.session.delete(instance)
154
+ await self.session.flush()
155
+
156
+ async def exists(self, field: str, value: Any) -> bool:
157
+ q = (
158
+ select(func.count())
159
+ .select_from(self.model)
160
+ .where(getattr(self.model, field) == value)
161
+ )
162
+ count = (await self.session.execute(q)).scalar_one()
163
+ return count > 0
164
+
165
+
166
+ class SyncBaseRepository(Generic[ModelT]):
167
+ def __init__(self, model: Type[ModelT], session: Session):
168
+ self.model = model
169
+ self.session = session
170
+
171
+ def _active_query(self) -> Select:
172
+ q = select(self.model)
173
+ if hasattr(self.model, "is_deleted"):
174
+ q = q.where(self.model.is_deleted == False)
175
+ return q
176
+
177
+ def create(self, data: Dict[str, Any]) -> ModelT:
178
+ instance = self.model(**data)
179
+ self.session.add(instance)
180
+ self.session.flush()
181
+ self.session.refresh(instance)
182
+ return instance
183
+
184
+ def get_by_id(self, id: UUID, include_deleted: bool = False) -> Optional[ModelT]:
185
+ q = select(self.model).where(self.model.id == id)
186
+ if not include_deleted and hasattr(self.model, "is_deleted"):
187
+ q = q.where(self.model.is_deleted == False)
188
+ return self.session.execute(q).scalar_one_or_none()
189
+
190
+ def get_by_field(self, field: str, value: Any) -> Optional[ModelT]:
191
+ q = self._active_query().where(getattr(self.model, field) == value)
192
+ return self.session.execute(q).scalar_one_or_none()
193
+
194
+ def get_all(
195
+ self,
196
+ page: int = 1,
197
+ page_size: int = 20,
198
+ pagination: bool = True,
199
+ filters: Optional[List] = None,
200
+ search: str | None = None,
201
+ search_columns: Optional[List] = None,
202
+ order_by=None,
203
+ sort_by: str | None = None,
204
+ sort_order: str | None = None,
205
+ ) -> Tuple[List[ModelT], int]:
206
+ q = self._active_query()
207
+ if filters:
208
+ q = q.where(*filters)
209
+ search_filter = text_search_filter(self.model, search, search_columns)
210
+ if search_filter is not None:
211
+ q = q.where(search_filter)
212
+ if isinstance(order_by, (list, tuple)):
213
+ default_order = tuple(order_by)
214
+ else:
215
+ default_order = (order_by,) if order_by is not None else ()
216
+ if not default_order and hasattr(self.model, "created_at"):
217
+ default_order = (self.model.created_at.desc(),)
218
+ order = sort_expressions(self.model, sort_by, sort_order, default_order)
219
+ if order:
220
+ q = q.order_by(*order)
221
+
222
+ total = self.session.execute(
223
+ select(func.count()).select_from(q.subquery())
224
+ ).scalar_one()
225
+
226
+ q = apply_pagination(q, page, page_size, pagination)
227
+
228
+ return list(self.session.execute(q).scalars().all()), total
229
+
230
+ def get_all_for_model(
231
+ self,
232
+ model: Type[ModelT],
233
+ page: int = 1,
234
+ page_size: int = 20,
235
+ pagination: bool = True,
236
+ filters: Optional[List] = None,
237
+ search: str | None = None,
238
+ search_columns: Optional[List] = None,
239
+ order_by=None,
240
+ sort_by: str | None = None,
241
+ sort_order: str | None = None,
242
+ ) -> Tuple[List[ModelT], int]:
243
+ q = select(model)
244
+ if hasattr(model, "is_deleted"):
245
+ q = q.where(model.is_deleted == False)
246
+ if filters:
247
+ q = q.where(*filters)
248
+ search_filter = text_search_filter(model, search, search_columns)
249
+ if search_filter is not None:
250
+ q = q.where(search_filter)
251
+ if isinstance(order_by, (list, tuple)):
252
+ default_order = tuple(order_by)
253
+ else:
254
+ default_order = (order_by,) if order_by is not None else ()
255
+ if not default_order and hasattr(model, "created_at"):
256
+ default_order = (model.created_at.desc(),)
257
+ order = sort_expressions(model, sort_by, sort_order, default_order)
258
+ if order:
259
+ q = q.order_by(*order)
260
+
261
+ total = self.session.execute(
262
+ select(func.count()).select_from(q.subquery())
263
+ ).scalar_one()
264
+
265
+ q = apply_pagination(q, page, page_size, pagination)
266
+
267
+ return list(self.session.execute(q).scalars().all()), total
268
+
269
+ def get_deleted(
270
+ self,
271
+ page: int = 1,
272
+ page_size: int = 20,
273
+ pagination: bool = True,
274
+ filters: Optional[List] = None,
275
+ search: str | None = None,
276
+ search_columns: Optional[List] = None,
277
+ order_by=None,
278
+ sort_by: str | None = None,
279
+ sort_order: str | None = None,
280
+ ) -> Tuple[List[ModelT], int]:
281
+ if not hasattr(self.model, "is_deleted"):
282
+ return [], 0
283
+
284
+ q = select(self.model).where(self.model.is_deleted == True)
285
+ if filters:
286
+ q = q.where(*filters)
287
+ search_filter = text_search_filter(self.model, search, search_columns)
288
+ if search_filter is not None:
289
+ q = q.where(search_filter)
290
+ if isinstance(order_by, (list, tuple)):
291
+ default_order = tuple(order_by)
292
+ else:
293
+ default_order = (order_by,) if order_by is not None else ()
294
+ if not default_order and hasattr(self.model, "deleted_at"):
295
+ default_order = (self.model.deleted_at.desc(),)
296
+ order = sort_expressions(self.model, sort_by, sort_order, default_order)
297
+ if order:
298
+ q = q.order_by(*order)
299
+
300
+ total = self.session.execute(
301
+ select(func.count()).select_from(q.subquery())
302
+ ).scalar_one()
303
+
304
+ q = apply_pagination(q, page, page_size, pagination)
305
+
306
+ return list(self.session.execute(q).scalars().all()), total
307
+
308
+ def update(self, instance: ModelT, data: Dict[str, Any]) -> ModelT:
309
+ for key, value in data.items():
310
+ if hasattr(instance, key):
311
+ setattr(instance, key, value)
312
+ self.session.add(instance)
313
+ self.session.flush()
314
+ self.session.refresh(instance)
315
+ return instance
316
+
317
+ def soft_delete(self, instance: ModelT, note: str) -> ModelT:
318
+ if not hasattr(instance, "soft_delete"):
319
+ raise AttributeError(f"{self.model.__name__} does not support soft delete")
320
+ instance.soft_delete(note)
321
+ self.session.add(instance)
322
+ self.session.flush()
323
+ return instance
324
+
325
+ def restore(self, instance: ModelT) -> ModelT:
326
+ if not hasattr(instance, "restore"):
327
+ raise AttributeError(f"{self.model.__name__} does not support restore")
328
+ instance.restore()
329
+ self.session.add(instance)
330
+ self.session.flush()
331
+ return instance
332
+
333
+ def hard_delete(self, instance: ModelT, note: str) -> None:
334
+ if hasattr(instance, "deletion_note"):
335
+ instance.deletion_note = note
336
+ self.session.add(instance)
337
+ self.session.flush()
338
+ self.session.delete(instance)
339
+ self.session.flush()
340
+
341
+ def exists(self, field: str, value: Any) -> bool:
342
+ count = self.session.execute(
343
+ select(func.count())
344
+ .select_from(self.model)
345
+ .where(getattr(self.model, field) == value)
346
+ ).scalar_one()
347
+ return count > 0