svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,253 @@
1
+ """
2
+ FastAPI integration for storage system.
3
+
4
+ Provides helpers to integrate storage backends with FastAPI applications.
5
+ """
6
+
7
+ import logging
8
+ from contextlib import asynccontextmanager
9
+ from typing import Optional, cast
10
+
11
+ from fastapi import FastAPI, HTTPException, Query, Request
12
+ from fastapi.responses import StreamingResponse
13
+
14
+ from .base import FileNotFoundError, PermissionDeniedError, StorageBackend, StorageError
15
+ from .easy import easy_storage
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def add_storage(
21
+ app: FastAPI,
22
+ backend: Optional[StorageBackend] = None,
23
+ serve_files: bool = False,
24
+ file_route_prefix: str = "/files",
25
+ ) -> StorageBackend:
26
+ """
27
+ Add storage backend to FastAPI application.
28
+
29
+ This function:
30
+ - Stores backend in app.state.storage
31
+ - Registers startup/shutdown hooks
32
+ - Optionally mounts file serving route
33
+ - Adds health check integration
34
+
35
+ Args:
36
+ app: FastAPI application instance
37
+ backend: Storage backend instance (auto-detected if None)
38
+ serve_files: If True, mount route to serve files (LocalBackend only)
39
+ file_route_prefix: URL prefix for file serving (default: "/files")
40
+
41
+ Returns:
42
+ Storage backend instance
43
+
44
+ Example:
45
+ >>> from fastapi import FastAPI
46
+ >>> from svc_infra.storage import add_storage, easy_storage
47
+ >>>
48
+ >>> app = FastAPI()
49
+ >>>
50
+ >>> # Auto-detect backend
51
+ >>> storage = add_storage(app)
52
+ >>>
53
+ >>> # Explicit backend
54
+ >>> backend = easy_storage(backend="s3", bucket="my-uploads")
55
+ >>> storage = add_storage(app, backend)
56
+ >>>
57
+ >>> # With file serving (LocalBackend only)
58
+ >>> backend = easy_storage(backend="local")
59
+ >>> storage = add_storage(app, backend, serve_files=True)
60
+
61
+ Note:
62
+ File serving is only supported for LocalBackend. For S3/GCS,
63
+ use presigned URLs instead.
64
+ """
65
+ # Auto-detect backend if not provided
66
+ if backend is None:
67
+ backend = easy_storage()
68
+
69
+ # Store in app state
70
+ app.state.storage = backend
71
+
72
+ # Get existing lifespan or create new one
73
+ existing_lifespan = getattr(app.router, "lifespan_context", None)
74
+
75
+ @asynccontextmanager
76
+ async def storage_lifespan(app: FastAPI):
77
+ # Startup
78
+ logger.info(f"Storage backend initialized: {backend.__class__.__name__}")
79
+
80
+ # Test connection for S3 backend
81
+ if hasattr(backend, "bucket"):
82
+ try:
83
+ # Try to list keys (limit 1 to minimize cost)
84
+ await backend.list_keys(limit=1)
85
+ logger.info(f"Successfully connected to storage: {backend.bucket}")
86
+ except Exception as e:
87
+ logger.error(f"Failed to connect to storage: {e}")
88
+ # Don't fail startup, let health check catch it
89
+
90
+ # Call existing lifespan if present
91
+ if existing_lifespan is not None:
92
+ async with existing_lifespan(app):
93
+ yield
94
+ else:
95
+ yield
96
+
97
+ # Shutdown
98
+ logger.info("Storage backend shutdown")
99
+
100
+ # Replace lifespan
101
+ app.router.lifespan_context = storage_lifespan
102
+
103
+ # Mount file serving route if requested (LocalBackend only)
104
+ if serve_files:
105
+ from .backends.local import LocalBackend
106
+
107
+ if not isinstance(backend, LocalBackend):
108
+ logger.warning(
109
+ f"File serving only supported for LocalBackend, "
110
+ f"got {backend.__class__.__name__}. Skipping route mount."
111
+ )
112
+ else:
113
+ # Create file serving route
114
+ @app.get(f"{file_route_prefix}/{{key:path}}")
115
+ async def serve_file(
116
+ key: str,
117
+ expires: str = Query(..., description="Expiration timestamp"),
118
+ signature: str = Query(..., description="HMAC signature"),
119
+ download: bool = Query(False, description="Force download"),
120
+ ):
121
+ """
122
+ Serve files from local storage with signature validation.
123
+
124
+ Requires valid signature generated by LocalBackend.get_url().
125
+ """
126
+ # Verify signature
127
+ if not backend.verify_url(key, expires, signature, download):
128
+ raise HTTPException(
129
+ status_code=403,
130
+ detail="Invalid or expired signature",
131
+ )
132
+
133
+ # Get file
134
+ try:
135
+ data = await backend.get(key)
136
+ metadata = await backend.get_metadata(key)
137
+
138
+ # Determine content disposition
139
+ if download:
140
+ filename = key.split("/")[-1]
141
+ content_disposition = f'attachment; filename="{filename}"'
142
+ else:
143
+ content_disposition = "inline"
144
+
145
+ # Return file
146
+ return StreamingResponse(
147
+ iter([data]),
148
+ media_type=metadata.get(
149
+ "content_type", "application/octet-stream"
150
+ ),
151
+ headers={
152
+ "Content-Disposition": content_disposition,
153
+ "Content-Length": str(len(data)),
154
+ },
155
+ )
156
+
157
+ except FileNotFoundError:
158
+ raise HTTPException(status_code=404, detail="File not found")
159
+ except PermissionDeniedError:
160
+ raise HTTPException(status_code=403, detail="Permission denied")
161
+ except StorageError as e:
162
+ logger.error(f"Storage error serving file {key}: {e}")
163
+ raise HTTPException(status_code=500, detail="Storage error")
164
+
165
+ logger.info(f"File serving enabled at {file_route_prefix}")
166
+
167
+ return backend
168
+
169
+
170
+ def get_storage(request: Request) -> StorageBackend:
171
+ """
172
+ FastAPI dependency to inject storage backend.
173
+
174
+ Use this in route handlers to access the storage backend.
175
+
176
+ Example:
177
+ >>> from fastapi import APIRouter, Depends, UploadFile
178
+ >>> from svc_infra.storage import get_storage, StorageBackend
179
+ >>>
180
+ >>> router = APIRouter()
181
+ >>>
182
+ >>> @router.post("/upload")
183
+ >>> async def upload_file(
184
+ ... file: UploadFile,
185
+ ... storage: StorageBackend = Depends(get_storage),
186
+ ... ):
187
+ ... content = await file.read()
188
+ ... url = await storage.put(
189
+ ... key=f"uploads/{file.filename}",
190
+ ... data=content,
191
+ ... content_type=file.content_type or "application/octet-stream"
192
+ ... )
193
+ ... return {"url": url}
194
+
195
+ Raises:
196
+ RuntimeError: If storage not initialized with add_storage()
197
+ """
198
+ if not hasattr(request.app.state, "storage"):
199
+ raise RuntimeError(
200
+ "Storage not initialized. "
201
+ "Call add_storage(app) during application setup."
202
+ )
203
+
204
+ return cast(StorageBackend, request.app.state.storage)
205
+
206
+
207
+ async def health_check_storage(request: Request) -> dict:
208
+ """
209
+ Health check for storage backend.
210
+
211
+ Returns storage status and basic statistics.
212
+
213
+ Example:
214
+ >>> from fastapi import FastAPI
215
+ >>> from svc_infra.storage import add_storage, health_check_storage
216
+ >>>
217
+ >>> app = FastAPI()
218
+ >>> add_storage(app)
219
+ >>>
220
+ >>> @app.get("/_health/storage")
221
+ >>> async def storage_health(request: Request):
222
+ ... return await health_check_storage(request)
223
+
224
+ Returns:
225
+ Dict with status and backend information
226
+ """
227
+ try:
228
+ storage = get_storage(request)
229
+
230
+ # Get backend type
231
+ backend_type = storage.__class__.__name__.replace("Backend", "").lower()
232
+
233
+ # Try a simple operation
234
+ await storage.list_keys(limit=1)
235
+
236
+ return {
237
+ "status": "healthy",
238
+ "backend": backend_type,
239
+ }
240
+
241
+ except Exception as e:
242
+ logger.error(f"Storage health check failed: {e}")
243
+ return {
244
+ "status": "unhealthy",
245
+ "error": str(e),
246
+ }
247
+
248
+
249
+ __all__ = [
250
+ "add_storage",
251
+ "get_storage",
252
+ "health_check_storage",
253
+ ]
@@ -0,0 +1,11 @@
1
+ """Storage backend implementations."""
2
+
3
+ from .local import LocalBackend
4
+ from .memory import MemoryBackend
5
+ from .s3 import S3Backend
6
+
7
+ __all__ = [
8
+ "LocalBackend",
9
+ "MemoryBackend",
10
+ "S3Backend",
11
+ ]
@@ -0,0 +1,339 @@
1
+ """
2
+ Local filesystem storage backend.
3
+
4
+ Ideal for Railway persistent volumes, Render disks, and local development.
5
+ """
6
+
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import secrets
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Optional, cast
14
+ from urllib.parse import urlencode
15
+
16
+ import aiofiles
17
+ import aiofiles.os
18
+
19
+ from ..base import FileNotFoundError as StorageFileNotFoundError
20
+ from ..base import InvalidKeyError, PermissionDeniedError, StorageError
21
+
22
+
23
+ class LocalBackend:
24
+ """
25
+ Local filesystem storage backend.
26
+
27
+ Stores files on the local filesystem with metadata stored as JSON sidecar files.
28
+ Supports HMAC-based signed URLs with expiration.
29
+
30
+ Args:
31
+ base_path: Base directory for file storage
32
+ base_url: Base URL for file serving (e.g., "http://localhost:8000/files")
33
+ signing_secret: Secret key for URL signing (auto-generated if not provided)
34
+
35
+ Example:
36
+ >>> # Railway persistent volume
37
+ >>> backend = LocalBackend(
38
+ ... base_path="/data/uploads",
39
+ ... base_url="https://api.example.com/files"
40
+ ... )
41
+ >>>
42
+ >>> # Local development
43
+ >>> backend = LocalBackend(
44
+ ... base_path="./uploads",
45
+ ... base_url="http://localhost:8000/files"
46
+ ... )
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ base_path: str = "/data/uploads",
52
+ base_url: str = "http://localhost:8000/files",
53
+ signing_secret: Optional[str] = None,
54
+ ):
55
+ self.base_path = Path(base_path)
56
+ self.base_url = base_url.rstrip("/")
57
+ self.signing_secret = signing_secret or secrets.token_urlsafe(32)
58
+
59
+ def _validate_key(self, key: str) -> None:
60
+ """Validate storage key format."""
61
+ if not key:
62
+ raise InvalidKeyError("Key cannot be empty")
63
+
64
+ if key.startswith("/"):
65
+ raise InvalidKeyError("Key cannot start with /")
66
+
67
+ if ".." in key:
68
+ raise InvalidKeyError("Key cannot contain .. (path traversal)")
69
+
70
+ if len(key) > 1024:
71
+ raise InvalidKeyError("Key cannot exceed 1024 characters")
72
+
73
+ # Check for safe characters
74
+ safe_chars = set(
75
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/"
76
+ )
77
+ if not all(c in safe_chars for c in key):
78
+ raise InvalidKeyError(
79
+ "Key can only contain alphanumeric, dot, dash, underscore, and slash"
80
+ )
81
+
82
+ def _get_file_path(self, key: str) -> Path:
83
+ """Get absolute file path for a key."""
84
+ return self.base_path / key
85
+
86
+ def _get_metadata_path(self, key: str) -> Path:
87
+ """Get metadata file path for a key."""
88
+ return self.base_path / f"{key}.meta.json"
89
+
90
+ def _sign_url(self, key: str, expires_at: int, download: bool) -> str:
91
+ """Generate HMAC signature for URL."""
92
+ message = f"{key}:{expires_at}:{download}"
93
+ signature = hmac.new(
94
+ self.signing_secret.encode(),
95
+ message.encode(),
96
+ hashlib.sha256,
97
+ ).hexdigest()
98
+ return signature
99
+
100
+ def _verify_signature(
101
+ self, key: str, expires_at: int, download: bool, signature: str
102
+ ) -> bool:
103
+ """Verify HMAC signature."""
104
+ expected = self._sign_url(key, expires_at, download)
105
+ return hmac.compare_digest(expected, signature)
106
+
107
+ async def put(
108
+ self,
109
+ key: str,
110
+ data: bytes,
111
+ content_type: str,
112
+ metadata: Optional[dict] = None,
113
+ ) -> str:
114
+ """Store file on local filesystem."""
115
+ self._validate_key(key)
116
+
117
+ file_path = self._get_file_path(key)
118
+ meta_path = self._get_metadata_path(key)
119
+
120
+ try:
121
+ # Create parent directories
122
+ await aiofiles.os.makedirs(file_path.parent, exist_ok=True)
123
+
124
+ # Write file atomically using temp file
125
+ temp_path = file_path.with_suffix(f"{file_path.suffix}.tmp")
126
+ async with aiofiles.open(temp_path, "wb") as f:
127
+ await f.write(data)
128
+
129
+ # Rename to final path (atomic on POSIX)
130
+ await aiofiles.os.rename(temp_path, file_path)
131
+
132
+ # Write metadata
133
+ meta_data = {
134
+ "size": len(data),
135
+ "content_type": content_type,
136
+ "created_at": datetime.now(timezone.utc).isoformat(),
137
+ **(metadata or {}),
138
+ }
139
+
140
+ async with aiofiles.open(meta_path, "w") as f:
141
+ await f.write(json.dumps(meta_data, indent=2))
142
+
143
+ except PermissionError as e:
144
+ raise PermissionDeniedError(f"Permission denied writing to {key}: {e}")
145
+ except OSError as e:
146
+ raise StorageError(f"Failed to write file {key}: {e}")
147
+
148
+ # Return signed URL (1 hour expiration)
149
+ return await self.get_url(key, expires_in=3600)
150
+
151
+ async def get(self, key: str) -> bytes:
152
+ """Retrieve file from local filesystem."""
153
+ self._validate_key(key)
154
+
155
+ file_path = self._get_file_path(key)
156
+
157
+ try:
158
+ async with aiofiles.open(file_path, "rb") as f:
159
+ return await f.read()
160
+ except FileNotFoundError:
161
+ raise StorageFileNotFoundError(f"File not found: {key}")
162
+ except PermissionError as e:
163
+ raise PermissionDeniedError(f"Permission denied reading {key}: {e}")
164
+ except OSError as e:
165
+ raise StorageError(f"Failed to read file {key}: {e}")
166
+
167
+ async def delete(self, key: str) -> bool:
168
+ """Delete file from local filesystem."""
169
+ self._validate_key(key)
170
+
171
+ file_path = self._get_file_path(key)
172
+ meta_path = self._get_metadata_path(key)
173
+
174
+ if not file_path.exists():
175
+ return False
176
+
177
+ try:
178
+ # Delete file
179
+ await aiofiles.os.remove(file_path)
180
+
181
+ # Delete metadata if exists
182
+ if meta_path.exists():
183
+ await aiofiles.os.remove(meta_path)
184
+
185
+ return True
186
+
187
+ except PermissionError as e:
188
+ raise PermissionDeniedError(f"Permission denied deleting {key}: {e}")
189
+ except OSError as e:
190
+ raise StorageError(f"Failed to delete file {key}: {e}")
191
+
192
+ async def exists(self, key: str) -> bool:
193
+ """Check if file exists on local filesystem."""
194
+ self._validate_key(key)
195
+
196
+ file_path = self._get_file_path(key)
197
+ return file_path.exists()
198
+
199
+ async def get_url(
200
+ self,
201
+ key: str,
202
+ expires_in: int = 3600,
203
+ download: bool = False,
204
+ ) -> str:
205
+ """
206
+ Generate signed URL for file access.
207
+
208
+ Args:
209
+ key: Storage key
210
+ expires_in: URL expiration in seconds (default: 1 hour)
211
+ download: If True, force download instead of inline display
212
+
213
+ Returns:
214
+ Signed URL with expiration and signature
215
+
216
+ Example:
217
+ >>> url = await backend.get_url("avatars/user_123/profile.jpg")
218
+ >>> # https://api.example.com/files/avatars/user_123/profile.jpg?expires=...&signature=...
219
+ """
220
+ self._validate_key(key)
221
+
222
+ # Check if file exists
223
+ if not await self.exists(key):
224
+ raise StorageFileNotFoundError(f"File not found: {key}")
225
+
226
+ # Calculate expiration timestamp
227
+ expires_at = int(datetime.now(timezone.utc).timestamp()) + expires_in
228
+
229
+ # Generate signature
230
+ signature = self._sign_url(key, expires_at, download)
231
+
232
+ # Build URL with query parameters
233
+ params = {
234
+ "expires": str(expires_at),
235
+ "signature": signature,
236
+ }
237
+ if download:
238
+ params["download"] = "true"
239
+
240
+ url = f"{self.base_url}/{key}?{urlencode(params)}"
241
+ return url
242
+
243
+ def verify_url(
244
+ self, key: str, expires: str, signature: str, download: bool = False
245
+ ) -> bool:
246
+ """
247
+ Verify a signed URL (for use in file serving endpoint).
248
+
249
+ Args:
250
+ key: Storage key
251
+ expires: Expiration timestamp as string
252
+ signature: HMAC signature
253
+ download: Download flag
254
+
255
+ Returns:
256
+ True if signature is valid and not expired
257
+
258
+ Example:
259
+ >>> # In file serving route
260
+ >>> if not backend.verify_url(key, expires, signature):
261
+ ... raise HTTPException(403, "Invalid signature")
262
+ """
263
+ try:
264
+ expires_at = int(expires)
265
+ except (ValueError, TypeError):
266
+ return False
267
+
268
+ # Check expiration
269
+ now = int(datetime.now(timezone.utc).timestamp())
270
+ if now > expires_at:
271
+ return False
272
+
273
+ # Verify signature
274
+ return self._verify_signature(key, expires_at, download, signature)
275
+
276
+ async def list_keys(
277
+ self,
278
+ prefix: str = "",
279
+ limit: int = 100,
280
+ ) -> list[str]:
281
+ """List stored keys with optional prefix filter."""
282
+ import os
283
+
284
+ prefix_path = self.base_path / prefix if prefix else self.base_path
285
+
286
+ if not prefix_path.exists():
287
+ return []
288
+
289
+ keys: list[str] = []
290
+
291
+ # Walk directory tree (using os.walk for Python 3.11 compatibility)
292
+ for root, _, files in os.walk(prefix_path):
293
+ for file in files:
294
+ # Skip metadata files
295
+ if file.endswith(".meta.json"):
296
+ continue
297
+
298
+ # Get relative path
299
+ file_path = Path(root) / file
300
+ relative = file_path.relative_to(self.base_path)
301
+ key = str(relative)
302
+
303
+ keys.append(key)
304
+
305
+ if len(keys) >= limit:
306
+ return keys
307
+
308
+ return keys
309
+
310
+ async def get_metadata(self, key: str) -> dict:
311
+ """Get file metadata."""
312
+ self._validate_key(key)
313
+
314
+ meta_path = self._get_metadata_path(key)
315
+
316
+ if not meta_path.exists():
317
+ # File exists but no metadata, create basic metadata
318
+ file_path = self._get_file_path(key)
319
+ if not file_path.exists():
320
+ raise StorageFileNotFoundError(f"File not found: {key}")
321
+
322
+ stat = await aiofiles.os.stat(file_path)
323
+ return {
324
+ "size": stat.st_size,
325
+ "content_type": "application/octet-stream",
326
+ "created_at": datetime.fromtimestamp(
327
+ stat.st_ctime, tz=timezone.utc
328
+ ).isoformat(),
329
+ }
330
+
331
+ try:
332
+ async with aiofiles.open(meta_path, "r") as f:
333
+ content = await f.read()
334
+ return cast(dict[Any, Any], json.loads(content))
335
+ except (OSError, json.JSONDecodeError) as e:
336
+ raise StorageError(f"Failed to read metadata for {key}: {e}")
337
+
338
+
339
+ __all__ = ["LocalBackend"]