fastapi-fullstack 0.1.2__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 (221) hide show
  1. fastapi_fullstack-0.1.2.dist-info/METADATA +545 -0
  2. fastapi_fullstack-0.1.2.dist-info/RECORD +221 -0
  3. fastapi_fullstack-0.1.2.dist-info/WHEEL +4 -0
  4. fastapi_fullstack-0.1.2.dist-info/entry_points.txt +2 -0
  5. fastapi_fullstack-0.1.2.dist-info/licenses/LICENSE +21 -0
  6. fastapi_gen/__init__.py +3 -0
  7. fastapi_gen/cli.py +256 -0
  8. fastapi_gen/config.py +255 -0
  9. fastapi_gen/generator.py +181 -0
  10. fastapi_gen/prompts.py +648 -0
  11. fastapi_gen/template/cookiecutter.json +76 -0
  12. fastapi_gen/template/hooks/post_gen_project.py +111 -0
  13. fastapi_gen/template/{{cookiecutter.project_slug}}/.env.example +136 -0
  14. fastapi_gen/template/{{cookiecutter.project_slug}}/.github/workflows/ci.yml +150 -0
  15. fastapi_gen/template/{{cookiecutter.project_slug}}/.gitignore +108 -0
  16. fastapi_gen/template/{{cookiecutter.project_slug}}/CLAUDE.md +357 -0
  17. fastapi_gen/template/{{cookiecutter.project_slug}}/Makefile +298 -0
  18. fastapi_gen/template/{{cookiecutter.project_slug}}/README.md +723 -0
  19. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/.dockerignore +60 -0
  20. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/.pre-commit-config.yaml +32 -0
  21. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/Dockerfile +56 -0
  22. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/env.py +76 -0
  23. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/script.py.mako +30 -0
  24. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/versions/.gitkeep +0 -0
  25. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic.ini +48 -0
  26. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/__init__.py +3 -0
  27. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/admin.py +115 -0
  28. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/__init__.py +13 -0
  29. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py +202 -0
  30. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/__init__.py +13 -0
  31. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/datetime_tool.py +17 -0
  32. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/__init__.py +1 -0
  33. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/deps.py +528 -0
  34. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/exception_handlers.py +85 -0
  35. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/router.py +10 -0
  36. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/__init__.py +9 -0
  37. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/__init__.py +87 -0
  38. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/agent.py +448 -0
  39. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/auth.py +395 -0
  40. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/conversations.py +490 -0
  41. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/health.py +227 -0
  42. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/items.py +275 -0
  43. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/oauth.py +205 -0
  44. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/sessions.py +168 -0
  45. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/users.py +333 -0
  46. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/webhooks.py +477 -0
  47. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/ws.py +46 -0
  48. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/versioning.py +221 -0
  49. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/clients/__init__.py +14 -0
  50. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/clients/redis.py +88 -0
  51. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/__init__.py +117 -0
  52. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/cleanup.py +75 -0
  53. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/example.py +28 -0
  54. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/seed.py +266 -0
  55. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/__init__.py +5 -0
  56. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/cache.py +23 -0
  57. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/config.py +247 -0
  58. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/csrf.py +153 -0
  59. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/exceptions.py +122 -0
  60. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/logfire_setup.py +101 -0
  61. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/middleware.py +99 -0
  62. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/oauth.py +23 -0
  63. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/rate_limit.py +58 -0
  64. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/sanitize.py +271 -0
  65. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/security.py +102 -0
  66. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/__init__.py +7 -0
  67. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/base.py +41 -0
  68. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/__init__.py +31 -0
  69. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/conversation.py +319 -0
  70. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/item.py +96 -0
  71. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/session.py +126 -0
  72. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/user.py +218 -0
  73. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/webhook.py +244 -0
  74. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/session.py +113 -0
  75. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/main.py +326 -0
  76. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/pipelines/__init__.py +9 -0
  77. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/pipelines/base.py +73 -0
  78. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/__init__.py +49 -0
  79. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/base.py +154 -0
  80. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/conversation.py +760 -0
  81. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/item.py +222 -0
  82. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/session.py +318 -0
  83. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/user.py +322 -0
  84. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/webhook.py +358 -0
  85. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/__init__.py +50 -0
  86. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/base.py +57 -0
  87. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/conversation.py +195 -0
  88. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/item.py +52 -0
  89. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/session.py +42 -0
  90. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/token.py +31 -0
  91. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/user.py +64 -0
  92. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/webhook.py +89 -0
  93. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/__init__.py +38 -0
  94. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/conversation.py +797 -0
  95. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/item.py +246 -0
  96. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/session.py +333 -0
  97. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/user.py +432 -0
  98. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/webhook.py +561 -0
  99. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/__init__.py +5 -0
  100. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/celery_app.py +64 -0
  101. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/taskiq_app.py +38 -0
  102. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/__init__.py +25 -0
  103. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/examples.py +106 -0
  104. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/schedules.py +29 -0
  105. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/taskiq_examples.py +92 -0
  106. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/cli/__init__.py +1 -0
  107. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/cli/commands.py +438 -0
  108. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +158 -0
  109. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/scripts/.gitkeep +0 -0
  110. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/__init__.py +1 -0
  111. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/__init__.py +1 -0
  112. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_auth.py +242 -0
  113. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_exceptions.py +151 -0
  114. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_health.py +113 -0
  115. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_items.py +310 -0
  116. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_users.py +253 -0
  117. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/conftest.py +151 -0
  118. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_agents.py +121 -0
  119. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_clients.py +183 -0
  120. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_commands.py +173 -0
  121. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_core.py +143 -0
  122. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_pipelines.py +118 -0
  123. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_repositories.py +181 -0
  124. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_security.py +124 -0
  125. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_services.py +363 -0
  126. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_worker.py +85 -0
  127. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml +242 -0
  128. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.frontend.yml +31 -0
  129. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.prod.yml +382 -0
  130. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.yml +241 -0
  131. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.env.example +12 -0
  132. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.gitignore +45 -0
  133. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.prettierignore +19 -0
  134. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.prettierrc +11 -0
  135. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/Dockerfile +44 -0
  136. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/README.md +693 -0
  137. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/e2e/auth.setup.ts +49 -0
  138. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/e2e/auth.spec.ts +134 -0
  139. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/e2e/chat.spec.ts +207 -0
  140. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/e2e/home.spec.ts +73 -0
  141. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/instrumentation.ts +14 -0
  142. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/messages/en.json +84 -0
  143. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/messages/pl.json +84 -0
  144. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/next.config.ts +76 -0
  145. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/package.json +66 -0
  146. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/playwright.config.ts +101 -0
  147. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/postcss.config.mjs +7 -0
  148. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(auth)/layout.tsx +11 -0
  149. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(auth)/login/page.tsx +5 -0
  150. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(auth)/register/page.tsx +5 -0
  151. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(dashboard)/chat/page.tsx +20 -0
  152. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(dashboard)/dashboard/page.tsx +99 -0
  153. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(dashboard)/layout.tsx +17 -0
  154. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(dashboard)/profile/page.tsx +156 -0
  155. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/login/route.ts +58 -0
  156. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/logout/route.ts +24 -0
  157. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/me/route.ts +39 -0
  158. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/oauth-callback/route.ts +50 -0
  159. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/refresh/route.ts +54 -0
  160. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/register/route.ts +26 -0
  161. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/conversations/[id]/messages/route.ts +41 -0
  162. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/conversations/[id]/route.ts +108 -0
  163. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/conversations/route.ts +73 -0
  164. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/health/route.ts +21 -0
  165. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/auth/callback/page.tsx +96 -0
  166. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/globals.css +108 -0
  167. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/layout.tsx +25 -0
  168. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/page.tsx +73 -0
  169. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/providers.tsx +29 -0
  170. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/auth/index.ts +2 -0
  171. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/auth/login-form.tsx +120 -0
  172. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/auth/register-form.tsx +153 -0
  173. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx +135 -0
  174. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-input.tsx +73 -0
  175. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/conversation-sidebar.tsx +261 -0
  176. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/index.ts +8 -0
  177. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/message-item.tsx +63 -0
  178. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/message-list.tsx +18 -0
  179. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx +60 -0
  180. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/icons/google-icon.tsx +32 -0
  181. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/icons/index.ts +3 -0
  182. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/language-switcher.tsx +97 -0
  183. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/layout/header.tsx +45 -0
  184. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/layout/index.ts +2 -0
  185. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/layout/sidebar.tsx +48 -0
  186. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/theme/index.ts +7 -0
  187. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/theme/theme-provider.tsx +53 -0
  188. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/theme/theme-toggle.tsx +83 -0
  189. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/badge.tsx +35 -0
  190. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/button.test.tsx +75 -0
  191. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/button.tsx +54 -0
  192. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/card.tsx +82 -0
  193. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts +12 -0
  194. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/input.tsx +21 -0
  195. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/label.tsx +21 -0
  196. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/index.ts +6 -0
  197. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-auth.ts +97 -0
  198. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts +203 -0
  199. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-conversations.ts +175 -0
  200. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-websocket.ts +105 -0
  201. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/i18n.ts +32 -0
  202. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/api-client.ts +90 -0
  203. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/constants.ts +39 -0
  204. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/server-api.ts +78 -0
  205. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/utils.test.ts +44 -0
  206. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/utils.ts +44 -0
  207. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/middleware.ts +33 -0
  208. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/auth-store.test.ts +72 -0
  209. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/auth-store.ts +48 -0
  210. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/chat-store.ts +65 -0
  211. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/conversation-store.ts +76 -0
  212. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/index.ts +6 -0
  213. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/theme-store.ts +44 -0
  214. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/api.ts +27 -0
  215. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/auth.ts +52 -0
  216. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts +81 -0
  217. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/conversation.ts +49 -0
  218. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/index.ts +10 -0
  219. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/tsconfig.json +28 -0
  220. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/vitest.config.ts +36 -0
  221. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/vitest.setup.ts +56 -0
@@ -0,0 +1,266 @@
1
+ {%- if cookiecutter.use_postgresql or cookiecutter.use_sqlite -%}
2
+ # ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
3
+ """
4
+ Seed database with sample data.
5
+
6
+ This command is useful for development and testing.
7
+ Uses random data generation - install faker for better data:
8
+ uv add faker --group dev
9
+ """
10
+
11
+ import asyncio
12
+ import random
13
+ import string
14
+
15
+ import click
16
+ from sqlalchemy import delete, select
17
+
18
+ from app.commands import command, info, success, warning
19
+
20
+ # Try to import Faker for better data generation
21
+ try:
22
+ from faker import Faker
23
+ fake = Faker()
24
+ HAS_FAKER = True
25
+ except ImportError:
26
+ HAS_FAKER = False
27
+ fake = None
28
+
29
+
30
+ def random_email() -> str:
31
+ """Generate a random email address."""
32
+ if HAS_FAKER:
33
+ return fake.email()
34
+ random_str = ''.join(random.choices(string.ascii_lowercase, k=8))
35
+ return f"{random_str}@example.com"
36
+
37
+
38
+ def random_name() -> str:
39
+ """Generate a random full name."""
40
+ if HAS_FAKER:
41
+ return fake.name()
42
+ first_names = ["John", "Jane", "Bob", "Alice", "Charlie", "Diana", "Eve", "Frank"]
43
+ last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis"]
44
+ return f"{random.choice(first_names)} {random.choice(last_names)}"
45
+
46
+
47
+ def random_title() -> str:
48
+ """Generate a random item title."""
49
+ if HAS_FAKER:
50
+ return fake.sentence(nb_words=4).rstrip('.')
51
+ adjectives = ["Amazing", "Great", "Awesome", "Fantastic", "Incredible", "Beautiful"]
52
+ nouns = ["Widget", "Gadget", "Thing", "Product", "Item", "Object"]
53
+ return f"{random.choice(adjectives)} {random.choice(nouns)}"
54
+
55
+
56
+ def random_description() -> str:
57
+ """Generate a random description."""
58
+ if HAS_FAKER:
59
+ return fake.paragraph(nb_sentences=3)
60
+ return "This is a sample description for development purposes."
61
+
62
+
63
+ @command("seed", help="Seed database with sample data")
64
+ @click.option("--count", "-c", default=10, type=int, help="Number of records to create")
65
+ @click.option("--clear", is_flag=True, help="Clear existing data before seeding")
66
+ @click.option("--dry-run", is_flag=True, help="Show what would be created without making changes")
67
+ {%- if cookiecutter.use_jwt %}
68
+ @click.option("--users/--no-users", default=True, help="Seed users (default: True)")
69
+ {%- endif %}
70
+ {%- if cookiecutter.include_example_crud %}
71
+ @click.option("--items/--no-items", default=True, help="Seed items (default: True)")
72
+ {%- endif %}
73
+ def seed(
74
+ count: int,
75
+ clear: bool,
76
+ dry_run: bool,
77
+ {%- if cookiecutter.use_jwt %}
78
+ users: bool,
79
+ {%- endif %}
80
+ {%- if cookiecutter.include_example_crud %}
81
+ items: bool,
82
+ {%- endif %}
83
+ ) -> None:
84
+ """
85
+ Seed the database with sample data for development.
86
+
87
+ Example:
88
+ project cmd seed --count 50
89
+ project cmd seed --clear --count 100
90
+ project cmd seed --dry-run
91
+ {%- if cookiecutter.use_jwt %}
92
+ project cmd seed --no-users --items # Only seed items
93
+ {%- endif %}
94
+ """
95
+ if not HAS_FAKER:
96
+ warning("Faker not installed. Using basic random data. For better data: uv add faker --group dev")
97
+
98
+ if dry_run:
99
+ info(f"[DRY RUN] Would create {count} sample records per entity")
100
+ if clear:
101
+ info("[DRY RUN] Would clear existing data first")
102
+ {%- if cookiecutter.use_jwt %}
103
+ if users:
104
+ info("[DRY RUN] Would create users")
105
+ {%- endif %}
106
+ {%- if cookiecutter.include_example_crud %}
107
+ if items:
108
+ info("[DRY RUN] Would create items")
109
+ {%- endif %}
110
+ return
111
+
112
+ {%- if cookiecutter.use_postgresql %}
113
+ from app.db.session import async_session_maker
114
+ {%- if cookiecutter.use_jwt %}
115
+ from app.db.models.user import User
116
+ from app.core.security import get_password_hash
117
+ {%- endif %}
118
+ {%- if cookiecutter.include_example_crud %}
119
+ from app.db.models.item import Item
120
+ {%- endif %}
121
+
122
+ async def _seed():
123
+ async with async_session_maker() as session:
124
+ created_counts = {}
125
+
126
+ {%- if cookiecutter.use_jwt %}
127
+ # Seed users
128
+ if users:
129
+ if clear:
130
+ info("Clearing existing users (except superusers)...")
131
+ await session.execute(delete(User).where(User.is_superuser == False)) # noqa: E712
132
+ await session.commit()
133
+
134
+ # Check how many users already exist
135
+ result = await session.execute(select(User).limit(1))
136
+ existing = result.scalars().first()
137
+
138
+ if existing and not clear:
139
+ info("Users already exist. Use --clear to replace them.")
140
+ else:
141
+ info(f"Creating {count} sample users...")
142
+ for _ in range(count):
143
+ user = User(
144
+ email=random_email(),
145
+ hashed_password=get_password_hash("password123"),
146
+ full_name=random_name(),
147
+ is_active=True,
148
+ is_superuser=False,
149
+ role="user",
150
+ )
151
+ session.add(user)
152
+ await session.commit()
153
+ created_counts["users"] = count
154
+ {%- endif %}
155
+
156
+ {%- if cookiecutter.include_example_crud %}
157
+ # Seed items
158
+ if items:
159
+ if clear:
160
+ info("Clearing existing items...")
161
+ await session.execute(delete(Item))
162
+ await session.commit()
163
+
164
+ # Check how many items already exist
165
+ result = await session.execute(select(Item).limit(1))
166
+ existing = result.scalars().first()
167
+
168
+ if existing and not clear:
169
+ info("Items already exist. Use --clear to replace them.")
170
+ else:
171
+ info(f"Creating {count} sample items...")
172
+ for _ in range(count):
173
+ item = Item(
174
+ title=random_title(),
175
+ description=random_description(),
176
+ is_active=random.choice([True, True, True, False]), # 75% active
177
+ )
178
+ session.add(item)
179
+ await session.commit()
180
+ created_counts["items"] = count
181
+ {%- endif %}
182
+
183
+ if created_counts:
184
+ summary = ", ".join(f"{v} {k}" for k, v in created_counts.items())
185
+ success(f"Created: {summary}")
186
+ else:
187
+ info("No records created.")
188
+
189
+ asyncio.run(_seed())
190
+ {%- elif cookiecutter.use_sqlite %}
191
+ from app.db.session import SessionLocal
192
+ {%- if cookiecutter.use_jwt %}
193
+ from app.db.models.user import User
194
+ from app.core.security import get_password_hash
195
+ {%- endif %}
196
+ {%- if cookiecutter.include_example_crud %}
197
+ from app.db.models.item import Item
198
+ {%- endif %}
199
+
200
+ with SessionLocal() as session:
201
+ created_counts = {}
202
+
203
+ {%- if cookiecutter.use_jwt %}
204
+ # Seed users
205
+ if users:
206
+ if clear:
207
+ info("Clearing existing users (except superusers)...")
208
+ session.execute(delete(User).where(User.is_superuser == False)) # noqa: E712
209
+ session.commit()
210
+
211
+ # Check how many users already exist
212
+ result = session.execute(select(User).limit(1))
213
+ existing = result.scalars().first()
214
+
215
+ if existing and not clear:
216
+ info("Users already exist. Use --clear to replace them.")
217
+ else:
218
+ info(f"Creating {count} sample users...")
219
+ for _ in range(count):
220
+ user = User(
221
+ email=random_email(),
222
+ hashed_password=get_password_hash("password123"),
223
+ full_name=random_name(),
224
+ is_active=True,
225
+ is_superuser=False,
226
+ role="user",
227
+ )
228
+ session.add(user)
229
+ session.commit()
230
+ created_counts["users"] = count
231
+ {%- endif %}
232
+
233
+ {%- if cookiecutter.include_example_crud %}
234
+ # Seed items
235
+ if items:
236
+ if clear:
237
+ info("Clearing existing items...")
238
+ session.execute(delete(Item))
239
+ session.commit()
240
+
241
+ # Check how many items already exist
242
+ result = session.execute(select(Item).limit(1))
243
+ existing = result.scalars().first()
244
+
245
+ if existing and not clear:
246
+ info("Items already exist. Use --clear to replace them.")
247
+ else:
248
+ info(f"Creating {count} sample items...")
249
+ for _ in range(count):
250
+ item = Item(
251
+ title=random_title(),
252
+ description=random_description(),
253
+ is_active=random.choice([True, True, True, False]), # 75% active
254
+ )
255
+ session.add(item)
256
+ session.commit()
257
+ created_counts["items"] = count
258
+ {%- endif %}
259
+
260
+ if created_counts:
261
+ summary = ", ".join(f"{v} {k}" for k, v in created_counts.items())
262
+ success(f"Created: {summary}")
263
+ else:
264
+ info("No records created.")
265
+ {%- endif %}
266
+ {%- endif %}
@@ -0,0 +1,5 @@
1
+ """Core application configuration and utilities."""
2
+
3
+ from .config import settings
4
+
5
+ __all__ = ["settings"]
@@ -0,0 +1,23 @@
1
+ {%- if cookiecutter.enable_caching %}
2
+ """Caching configuration using fastapi-cache2."""
3
+
4
+ from fastapi_cache import FastAPICache
5
+ from fastapi_cache.backends.redis import RedisBackend
6
+
7
+ from app.clients.redis import RedisClient
8
+
9
+
10
+ def setup_cache(redis: RedisClient) -> None:
11
+ """Initialize FastAPI cache with Redis backend.
12
+
13
+ Uses the shared Redis client from lifespan state.
14
+ """
15
+ FastAPICache.init(RedisBackend(redis.raw), prefix="{{ cookiecutter.project_slug }}:cache:")
16
+ {%- else %}
17
+ """Caching - not configured."""
18
+
19
+
20
+ async def setup_cache() -> None:
21
+ """No-op when caching is disabled."""
22
+ pass
23
+ {%- endif %}
@@ -0,0 +1,247 @@
1
+ """Application configuration using Pydantic BaseSettings."""
2
+ {% if cookiecutter.use_database -%}
3
+ # ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
4
+ {% endif %}
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ {% if cookiecutter.use_database -%}
9
+ from pydantic import computed_field, field_validator
10
+ {% else -%}
11
+ from pydantic import field_validator
12
+ {% endif -%}
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+
16
+ def find_env_file() -> Path | None:
17
+ """Find .env file in current or parent directories."""
18
+ current = Path.cwd()
19
+ for path in [current, current.parent]:
20
+ env_file = path / ".env"
21
+ if env_file.exists():
22
+ return env_file
23
+ return None
24
+
25
+
26
+ class Settings(BaseSettings):
27
+ """Application settings."""
28
+
29
+ model_config = SettingsConfigDict(
30
+ env_file=find_env_file(),
31
+ env_ignore_empty=True,
32
+ extra="ignore",
33
+ )
34
+
35
+ # === Project ===
36
+ PROJECT_NAME: str = "{{ cookiecutter.project_name }}"
37
+ API_V1_STR: str = "/api/v1"
38
+ DEBUG: bool = False
39
+ ENVIRONMENT: Literal["development", "local", "staging", "production"] = "local"
40
+
41
+ {%- if cookiecutter.enable_logfire %}
42
+
43
+ # === Logfire ===
44
+ LOGFIRE_TOKEN: str | None = None
45
+ LOGFIRE_SERVICE_NAME: str = "{{ cookiecutter.project_slug }}"
46
+ LOGFIRE_ENVIRONMENT: str = "development"
47
+ {%- endif %}
48
+
49
+ {%- if cookiecutter.use_postgresql %}
50
+
51
+ # === Database (PostgreSQL async) ===
52
+ POSTGRES_HOST: str = "localhost"
53
+ POSTGRES_PORT: int = 5432
54
+ POSTGRES_USER: str = "postgres"
55
+ POSTGRES_PASSWORD: str = ""
56
+ POSTGRES_DB: str = "{{ cookiecutter.project_slug }}"
57
+
58
+ @computed_field # type: ignore[prop-decorator]
59
+ @property
60
+ def DATABASE_URL(self) -> str:
61
+ """Build async PostgreSQL connection URL."""
62
+ return (
63
+ f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
64
+ f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
65
+ )
66
+
67
+ @computed_field # type: ignore[prop-decorator]
68
+ @property
69
+ def DATABASE_URL_SYNC(self) -> str:
70
+ """Build sync PostgreSQL connection URL (for Alembic)."""
71
+ return (
72
+ f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
73
+ f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
74
+ )
75
+
76
+ # Pool configuration
77
+ DB_POOL_SIZE: int = {{ cookiecutter.db_pool_size }}
78
+ DB_MAX_OVERFLOW: int = {{ cookiecutter.db_max_overflow }}
79
+ DB_POOL_TIMEOUT: int = {{ cookiecutter.db_pool_timeout }}
80
+ {%- endif %}
81
+
82
+ {%- if cookiecutter.use_mongodb %}
83
+
84
+ # === Database (MongoDB async) ===
85
+ MONGO_HOST: str = "localhost"
86
+ MONGO_PORT: int = 27017
87
+ MONGO_DB: str = "{{ cookiecutter.project_slug }}"
88
+ MONGO_USER: str | None = None
89
+ MONGO_PASSWORD: str | None = None
90
+
91
+ @computed_field # type: ignore[prop-decorator]
92
+ @property
93
+ def MONGO_URL(self) -> str:
94
+ """Build MongoDB connection URL."""
95
+ if self.MONGO_USER and self.MONGO_PASSWORD:
96
+ return f"mongodb://{self.MONGO_USER}:{self.MONGO_PASSWORD}@{self.MONGO_HOST}:{self.MONGO_PORT}"
97
+ return f"mongodb://{self.MONGO_HOST}:{self.MONGO_PORT}"
98
+ {%- endif %}
99
+
100
+ {%- if cookiecutter.use_sqlite %}
101
+
102
+ # === Database (SQLite sync) ===
103
+ SQLITE_PATH: str = "./{{ cookiecutter.project_slug }}.db"
104
+
105
+ @computed_field # type: ignore[prop-decorator]
106
+ @property
107
+ def DATABASE_URL(self) -> str:
108
+ """Build SQLite connection URL."""
109
+ return f"sqlite:///{self.SQLITE_PATH}"
110
+ {%- endif %}
111
+
112
+ {%- if cookiecutter.use_jwt %}
113
+
114
+ # === Auth (JWT) ===
115
+ SECRET_KEY: str = "change-me-in-production-use-openssl-rand-hex-32"
116
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # 30 minutes
117
+ REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
118
+ ALGORITHM: str = "HS256"
119
+
120
+ @field_validator("SECRET_KEY")
121
+ @classmethod
122
+ def validate_secret_key(cls, v: str, info) -> str:
123
+ """Validate SECRET_KEY is secure in production."""
124
+ if len(v) < 32:
125
+ raise ValueError("SECRET_KEY must be at least 32 characters long")
126
+ # Get environment from values if available
127
+ env = info.data.get("ENVIRONMENT", "local") if info.data else "local"
128
+ if v == "change-me-in-production-use-openssl-rand-hex-32" and env == "production":
129
+ raise ValueError(
130
+ "SECRET_KEY must be changed in production! "
131
+ "Generate a secure key with: openssl rand -hex 32"
132
+ )
133
+ return v
134
+ {%- endif %}
135
+
136
+ {%- if cookiecutter.enable_oauth_google %}
137
+
138
+ # === OAuth2 (Google) ===
139
+ GOOGLE_CLIENT_ID: str = ""
140
+ GOOGLE_CLIENT_SECRET: str = ""
141
+ GOOGLE_REDIRECT_URI: str = "http://localhost:{{ cookiecutter.backend_port }}/api/v1/oauth/google/callback"
142
+ {%- endif %}
143
+
144
+ {%- if cookiecutter.use_api_key %}
145
+
146
+ # === Auth (API Key) ===
147
+ API_KEY: str = "change-me-in-production"
148
+ API_KEY_HEADER: str = "X-API-Key"
149
+
150
+ @field_validator("API_KEY")
151
+ @classmethod
152
+ def validate_api_key(cls, v: str, info) -> str:
153
+ """Validate API_KEY is set in production."""
154
+ env = info.data.get("ENVIRONMENT", "local") if info.data else "local"
155
+ if v == "change-me-in-production" and env == "production":
156
+ raise ValueError(
157
+ "API_KEY must be changed in production! "
158
+ "Generate a secure key with: openssl rand -hex 32"
159
+ )
160
+ return v
161
+ {%- endif %}
162
+
163
+ {%- if cookiecutter.enable_redis %}
164
+
165
+ # === Redis ===
166
+ REDIS_HOST: str = "localhost"
167
+ REDIS_PORT: int = 6379
168
+ REDIS_PASSWORD: str | None = None
169
+ REDIS_DB: int = 0
170
+
171
+ @computed_field # type: ignore[prop-decorator]
172
+ @property
173
+ def REDIS_URL(self) -> str:
174
+ """Build Redis connection URL."""
175
+ if self.REDIS_PASSWORD:
176
+ return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
177
+ return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
178
+ {%- endif %}
179
+
180
+ {%- if cookiecutter.enable_rate_limiting %}
181
+
182
+ # === Rate Limiting ===
183
+ RATE_LIMIT_REQUESTS: int = {{ cookiecutter.rate_limit_requests }}
184
+ RATE_LIMIT_PERIOD: int = {{ cookiecutter.rate_limit_period }} # seconds
185
+ {%- endif %}
186
+
187
+ {%- if cookiecutter.use_celery %}
188
+
189
+ # === Celery ===
190
+ CELERY_BROKER_URL: str = "redis://localhost:6379/0"
191
+ CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0"
192
+ {%- endif %}
193
+
194
+ {%- if cookiecutter.use_taskiq %}
195
+
196
+ # === Taskiq ===
197
+ TASKIQ_BROKER_URL: str = "redis://localhost:6379/1"
198
+ TASKIQ_RESULT_BACKEND: str = "redis://localhost:6379/1"
199
+ {%- endif %}
200
+
201
+ {%- if cookiecutter.enable_sentry %}
202
+
203
+ # === Sentry ===
204
+ SENTRY_DSN: str | None = None
205
+ {%- endif %}
206
+
207
+ {%- if cookiecutter.enable_file_storage %}
208
+
209
+ # === File Storage (S3/MinIO) ===
210
+ S3_ENDPOINT: str | None = None
211
+ S3_ACCESS_KEY: str = ""
212
+ S3_SECRET_KEY: str = ""
213
+ S3_BUCKET: str = "{{ cookiecutter.project_slug }}"
214
+ S3_REGION: str = "us-east-1"
215
+ {%- endif %}
216
+
217
+ {%- if cookiecutter.enable_ai_agent %}
218
+
219
+ # === AI Agent (PydanticAI) ===
220
+ OPENAI_API_KEY: str = ""
221
+ AI_MODEL: str = "gpt-4o-mini"
222
+ AI_TEMPERATURE: float = 0.7
223
+ {%- endif %}
224
+
225
+ {%- if cookiecutter.enable_cors %}
226
+
227
+ # === CORS ===
228
+ CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8080"]
229
+ CORS_ALLOW_CREDENTIALS: bool = True
230
+ CORS_ALLOW_METHODS: list[str] = ["*"]
231
+ CORS_ALLOW_HEADERS: list[str] = ["*"]
232
+
233
+ @field_validator("CORS_ORIGINS")
234
+ @classmethod
235
+ def validate_cors_origins(cls, v: list[str], info) -> list[str]:
236
+ """Warn if CORS_ORIGINS is too permissive in production."""
237
+ env = info.data.get("ENVIRONMENT", "local") if info.data else "local"
238
+ if "*" in v and env == "production":
239
+ raise ValueError(
240
+ "CORS_ORIGINS cannot contain '*' in production! "
241
+ "Specify explicit allowed origins."
242
+ )
243
+ return v
244
+ {%- endif %}
245
+
246
+
247
+ settings = Settings()
@@ -0,0 +1,153 @@
1
+ {%- if cookiecutter.use_jwt %}
2
+ """CSRF protection middleware for FastAPI.
3
+
4
+ This module provides CSRF (Cross-Site Request Forgery) protection for
5
+ state-changing HTTP methods (POST, PUT, PATCH, DELETE).
6
+
7
+ The protection works by:
8
+ 1. Setting a CSRF token in a cookie on initial request
9
+ 2. Requiring the token to be sent in a header for state-changing requests
10
+ 3. Comparing the cookie token with the header token
11
+
12
+ Usage:
13
+ Add to your main.py:
14
+
15
+ from app.core.csrf import CSRFMiddleware
16
+
17
+ app.add_middleware(CSRFMiddleware)
18
+
19
+ For endpoints that should be exempt (e.g., login):
20
+
21
+ @router.post("/login", tags=["csrf-exempt"])
22
+ async def login(...):
23
+ ...
24
+ """
25
+
26
+ import secrets
27
+ from collections.abc import Callable
28
+ from typing import ClassVar
29
+
30
+ from fastapi import Request, Response
31
+ from fastapi.responses import JSONResponse
32
+ from starlette.middleware.base import BaseHTTPMiddleware
33
+
34
+ from app.core.config import settings
35
+
36
+
37
+ class CSRFMiddleware(BaseHTTPMiddleware):
38
+ """CSRF protection middleware.
39
+
40
+ Protects against Cross-Site Request Forgery attacks by requiring
41
+ a token to be present in both a cookie and a header for state-changing requests.
42
+ """
43
+
44
+ # Methods that require CSRF protection
45
+ PROTECTED_METHODS: ClassVar[set[str]] = {"POST", "PUT", "PATCH", "DELETE"}
46
+
47
+ # Cookie settings
48
+ COOKIE_NAME: ClassVar[str] = "csrf_token"
49
+ HEADER_NAME: ClassVar[str] = "X-CSRF-Token"
50
+
51
+ # Paths to exclude from CSRF protection
52
+ EXEMPT_PATHS: ClassVar[set[str]] = {
53
+ "/api/v1/auth/login",
54
+ "/api/v1/auth/register",
55
+ "/api/v1/auth/refresh",
56
+ "/api/v1/health",
57
+ "/api/v1/ready",
58
+ "/docs",
59
+ "/openapi.json",
60
+ "/redoc",
61
+ }
62
+
63
+ def __init__(self, app: Callable, **kwargs):
64
+ super().__init__(app)
65
+ self.exempt_paths = set(kwargs.get("exempt_paths", self.EXEMPT_PATHS))
66
+ self.cookie_name = kwargs.get("cookie_name", self.COOKIE_NAME)
67
+ self.header_name = kwargs.get("header_name", self.HEADER_NAME)
68
+
69
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
70
+ """Handle the request and apply CSRF protection."""
71
+ # Skip for exempt paths
72
+ if self._is_exempt(request):
73
+ return await call_next(request)
74
+
75
+ # Get or generate CSRF token
76
+ csrf_token = request.cookies.get(self.cookie_name)
77
+ if not csrf_token:
78
+ csrf_token = self._generate_token()
79
+
80
+ # Check CSRF for protected methods
81
+ if request.method in self.PROTECTED_METHODS:
82
+ header_token = request.headers.get(self.header_name)
83
+
84
+ if not header_token:
85
+ return JSONResponse(
86
+ status_code=403,
87
+ content={
88
+ "detail": "CSRF token missing",
89
+ "message": f"Include the '{self.header_name}' header with the CSRF token",
90
+ },
91
+ )
92
+
93
+ if not secrets.compare_digest(csrf_token, header_token):
94
+ return JSONResponse(
95
+ status_code=403,
96
+ content={
97
+ "detail": "CSRF token invalid",
98
+ "message": "The CSRF token does not match",
99
+ },
100
+ )
101
+
102
+ # Process the request
103
+ response = await call_next(request)
104
+
105
+ # Set CSRF token cookie if not present
106
+ if not request.cookies.get(self.cookie_name):
107
+ response.set_cookie(
108
+ key=self.cookie_name,
109
+ value=csrf_token,
110
+ httponly=False, # JavaScript needs to read this
111
+ secure=not settings.DEBUG,
112
+ samesite="lax",
113
+ max_age=3600 * 24, # 24 hours
114
+ )
115
+
116
+ return response
117
+
118
+ def _is_exempt(self, request: Request) -> bool:
119
+ """Check if the request path is exempt from CSRF protection."""
120
+ path = request.url.path
121
+
122
+ # Check exact path matches
123
+ if path in self.exempt_paths:
124
+ return True
125
+
126
+ # Check path prefixes
127
+ for exempt in self.exempt_paths:
128
+ if path.startswith(exempt):
129
+ return True
130
+
131
+ # Check if endpoint has "csrf-exempt" tag
132
+ route = request.scope.get("route")
133
+ return bool(route and hasattr(route, "tags") and "csrf-exempt" in route.tags)
134
+
135
+ @staticmethod
136
+ def _generate_token() -> str:
137
+ """Generate a secure CSRF token."""
138
+ return secrets.token_urlsafe(32)
139
+
140
+
141
+ def get_csrf_token(request: Request) -> str:
142
+ """Get the current CSRF token from cookies or generate a new one.
143
+
144
+ Use this in templates or API responses to provide the token to clients.
145
+ """
146
+ token = request.cookies.get(CSRFMiddleware.COOKIE_NAME)
147
+ if not token:
148
+ token = secrets.token_urlsafe(32)
149
+ return token
150
+
151
+ {%- else %}
152
+ """CSRF protection - authentication not enabled."""
153
+ {%- endif %}