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,70 @@
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import Boolean, DateTime, Text, func
5
+ from sqlalchemy.dialects.postgresql import UUID
6
+ from sqlalchemy.orm import Mapped, mapped_column
7
+
8
+
9
+ def utcnow():
10
+ return datetime.now(timezone.utc)
11
+
12
+
13
+ class UUIDMixin:
14
+ """Provides a UUID primary key."""
15
+
16
+ id: Mapped[uuid.UUID] = mapped_column(
17
+ UUID(as_uuid=True),
18
+ primary_key=True,
19
+ default=uuid.uuid4,
20
+ index=True,
21
+ )
22
+
23
+
24
+ class TimestampMixin:
25
+ """Adds created_at / updated_at columns with automatic server defaults."""
26
+
27
+ created_at: Mapped[datetime] = mapped_column(
28
+ DateTime(timezone=True),
29
+ server_default=func.now(),
30
+ nullable=False,
31
+ )
32
+ updated_at: Mapped[datetime] = mapped_column(
33
+ DateTime(timezone=True),
34
+ server_default=func.now(),
35
+ onupdate=func.now(),
36
+ nullable=False,
37
+ )
38
+
39
+
40
+ class SoftDeleteMixin:
41
+ """
42
+ Provides soft-delete support.
43
+ - is_deleted=True → soft-deleted (row hidden from normal queries)
44
+ - deleted_at → timestamp of soft-deletion
45
+ Call .hard_delete() on the session to permanently remove the row.
46
+ """
47
+
48
+ is_deleted: Mapped[bool] = mapped_column(
49
+ Boolean, default=False, nullable=False, index=True
50
+ )
51
+ deleted_at: Mapped[datetime | None] = mapped_column(
52
+ DateTime(timezone=True), nullable=True
53
+ )
54
+ deletion_note: Mapped[str | None] = mapped_column(Text, nullable=True)
55
+
56
+ def soft_delete(self, note: str | None = None) -> None:
57
+ self.is_deleted = True
58
+ self.deleted_at = utcnow()
59
+ self.deletion_note = note
60
+
61
+ def restore(self) -> None:
62
+ self.is_deleted = False
63
+ self.deleted_at = None
64
+ self.deletion_note = None
65
+
66
+
67
+ class BaseModelMixin(UUIDMixin, TimestampMixin, SoftDeleteMixin):
68
+ """Convenience mixin that combines UUID PK, timestamps, and soft-delete."""
69
+
70
+ pass
@@ -0,0 +1,5 @@
1
+ APP_SCHEMA = "app"
2
+ AUTH_SCHEMA = "auth"
3
+
4
+ ALL_SCHEMAS = (AUTH_SCHEMA, APP_SCHEMA)
5
+
@@ -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, superuser authorization, 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)