svc-infra 0.1.640__py3-none-any.whl → 0.1.664__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 (33) hide show
  1. svc_infra/api/fastapi/apf_payments/setup.py +0 -2
  2. svc_infra/api/fastapi/auth/add.py +0 -4
  3. svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
  4. svc_infra/api/fastapi/cache/add.py +9 -5
  5. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  6. svc_infra/api/fastapi/db/sql/add.py +8 -5
  7. svc_infra/api/fastapi/db/sql/crud_router.py +4 -4
  8. svc_infra/api/fastapi/docs/scoped.py +41 -6
  9. svc_infra/api/fastapi/setup.py +10 -12
  10. svc_infra/api/fastapi/versioned.py +101 -0
  11. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  12. svc_infra/db/sql/templates/setup/env_async.py.tmpl +25 -11
  13. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +20 -5
  14. svc_infra/docs/acceptance-matrix.md +17 -0
  15. svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
  16. svc_infra/docs/api.md +127 -0
  17. svc_infra/docs/storage.md +982 -0
  18. svc_infra/docs/versioned-integrations.md +146 -0
  19. svc_infra/security/models.py +27 -7
  20. svc_infra/security/oauth_models.py +59 -0
  21. svc_infra/storage/__init__.py +93 -0
  22. svc_infra/storage/add.py +250 -0
  23. svc_infra/storage/backends/__init__.py +11 -0
  24. svc_infra/storage/backends/local.py +331 -0
  25. svc_infra/storage/backends/memory.py +214 -0
  26. svc_infra/storage/backends/s3.py +329 -0
  27. svc_infra/storage/base.py +239 -0
  28. svc_infra/storage/easy.py +182 -0
  29. svc_infra/storage/settings.py +192 -0
  30. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/METADATA +8 -3
  31. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/RECORD +33 -19
  32. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
  33. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,146 @@
1
+ # Using add_* Functions Under Versioned Routing
2
+
3
+ ## Problem
4
+
5
+ By default, `add_*` functions from svc-infra and fin-infra mount routes at root level (e.g., `/banking/*`, `/_sql/*`). However, you may want all features consolidated under a single versioned API prefix (e.g., `/v0/banking`) to keep your API organized under version namespaces.
6
+
7
+ ## Simple Solution (Recommended)
8
+
9
+ Use the `extract_router()` helper:
10
+
11
+ ```python
12
+ # src/your_api/routers/v0/banking.py
13
+ from svc_infra.api.fastapi.versioned import extract_router
14
+ from fin_infra.banking import add_banking
15
+
16
+ # Extract router and provider from add_banking()
17
+ router, banking_provider = extract_router(
18
+ add_banking,
19
+ prefix="/banking",
20
+ provider="plaid",
21
+ cache_ttl=60,
22
+ )
23
+
24
+ # That's it! svc-infra auto-discovers 'router' and mounts at /v0/banking
25
+ ```
26
+
27
+ ### Result
28
+
29
+ - ✅ All banking endpoints under `/v0/banking/*`
30
+ - ✅ Banking docs included in `/v0/docs` (not separate card)
31
+ - ✅ Full `add_banking()` functionality preserved
32
+ - ✅ Returns provider instance for additional use
33
+
34
+ ## Complete Example
35
+
36
+ ```python
37
+ # Directory structure
38
+ your_api/
39
+ routers/
40
+ v0/
41
+ __init__.py
42
+ status.py
43
+ banking.py # <- Integration using helper
44
+ payments.py # <- Another integration
45
+
46
+ # banking.py - Clean and simple
47
+ """Banking integration under v0 routing."""
48
+ from svc_infra.api.fastapi.versioned import extract_router
49
+ from fin_infra.banking import add_banking
50
+
51
+ router, banking_provider = extract_router(
52
+ add_banking,
53
+ prefix="/banking",
54
+ provider="plaid", # or "teller"
55
+ cache_ttl=60,
56
+ )
57
+
58
+ # Optional: Store provider on app state for later use
59
+ # This happens in app.py after router discovery:
60
+ # app.state.banking = banking_provider
61
+ ```
62
+
63
+ ## Works With
64
+
65
+ Any svc-infra or fin-infra function that calls `app.include_router()`:
66
+
67
+ ```python
68
+ # Banking integration
69
+ from fin_infra.banking import add_banking
70
+ router, provider = extract_router(add_banking, prefix="/banking", provider="plaid")
71
+
72
+ # Market data
73
+ from fin_infra.markets import add_market_data
74
+ router, provider = extract_router(add_market_data, prefix="/markets")
75
+
76
+ # Analytics
77
+ from fin_infra.analytics import add_analytics
78
+ router, provider = extract_router(add_analytics, prefix="/analytics")
79
+
80
+ # Budgets
81
+ from fin_infra.budgets import add_budgets
82
+ router, provider = extract_router(add_budgets, prefix="/budgets")
83
+
84
+ # Documents
85
+ from fin_infra.documents import add_documents
86
+ router, provider = extract_router(add_documents, prefix="/documents")
87
+
88
+ # Any custom add_* function following the pattern
89
+ ```
90
+
91
+ ## When to Use
92
+
93
+ **Use when:**
94
+ - Building a monolithic versioned API where all features belong under `/v0`, `/v1`, etc.
95
+ - You want unified documentation at `/v0/docs` showing all features together
96
+ - You're consolidating multiple integrations under one version
97
+ - You need version-specific behavior for third-party integrations
98
+
99
+ **Don't use when:**
100
+ - Feature should have its own root-level endpoint (e.g., public webhooks at `/webhooks`)
101
+ - Integration is shared across multiple versions (mount at root instead)
102
+ - You only need a subset of endpoints (define manually)
103
+
104
+ ## Alternative: Manual Definition
105
+
106
+ For simple integrations, define routes manually:
107
+
108
+ ```python
109
+ # routers/v0/banking.py
110
+ from svc_infra.api.fastapi.dual.public import public_router
111
+ from fin_infra.banking import easy_banking
112
+
113
+ router = public_router(prefix="/banking", tags=["Banking"])
114
+ banking = easy_banking(provider="plaid")
115
+
116
+ @router.post("/link")
117
+ async def create_link(request: CreateLinkRequest):
118
+ return banking.create_link_token(user_id=request.user_id)
119
+
120
+ # ... define other endpoints
121
+ ```
122
+
123
+ Use manual definition when:
124
+ - Only need a subset of integration endpoints
125
+ - Want custom validation/transforms per endpoint
126
+ - Integration is very simple (2-3 endpoints)
127
+ - Need version-specific behavior per endpoint
128
+
129
+ ## How It Works
130
+
131
+ The `extract_router()` helper:
132
+
133
+ 1. **Creates Mock App**: Temporary FastAPI instance to capture router
134
+ 2. **Intercepts Router**: Monkey-patches `include_router()` to capture instead of mount
135
+ 3. **Calls Integration**: Runs `add_*()` function which creates all routes normally
136
+ 4. **Returns Router**: Exports captured router for svc-infra auto-discovery
137
+ 5. **Auto-Mounts**: svc-infra finds `router` in `v0.banking` and mounts at `/v0/banking`
138
+
139
+ The provider/integration instance is also returned for additional use if needed.
140
+
141
+ ## See Also
142
+
143
+ - [API Versioning](./api.md#versioning) - How svc-infra version routing works
144
+ - [Router Auto-Discovery](./api.md#router-discovery) - How routers are found and mounted
145
+ - [Dual Routers](./api.md#dual-routers) - Similar pattern for public/protected routers
146
+ - `svc_infra.api.fastapi.versioned` - Source code for helper function
@@ -6,7 +6,17 @@ import uuid
6
6
  from datetime import datetime, timedelta, timezone
7
7
  from typing import Optional
8
8
 
9
- from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint
9
+ from sqlalchemy import (
10
+ JSON,
11
+ Boolean,
12
+ DateTime,
13
+ ForeignKey,
14
+ Index,
15
+ String,
16
+ Text,
17
+ UniqueConstraint,
18
+ text,
19
+ )
10
20
  from sqlalchemy.orm import Mapped, mapped_column, relationship
11
21
 
12
22
  from svc_infra.db.sql.base import ModelBase
@@ -34,7 +44,7 @@ class AuthSession(ModelBase):
34
44
  )
35
45
 
36
46
  created_at = mapped_column(
37
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
47
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
38
48
  )
39
49
 
40
50
 
@@ -54,7 +64,7 @@ class RefreshToken(ModelBase):
54
64
  expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
55
65
 
56
66
  created_at = mapped_column(
57
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
67
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
58
68
  )
59
69
 
60
70
  __table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
@@ -126,7 +136,7 @@ class Organization(ModelBase):
126
136
  slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
127
137
  tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
128
138
  created_at = mapped_column(
129
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
139
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
130
140
  )
131
141
 
132
142
 
@@ -139,7 +149,7 @@ class Team(ModelBase):
139
149
  )
140
150
  name: Mapped[str] = mapped_column(String(128), nullable=False)
141
151
  created_at = mapped_column(
142
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
152
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
143
153
  )
144
154
 
145
155
 
@@ -155,7 +165,7 @@ class OrganizationMembership(ModelBase):
155
165
  )
156
166
  role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
157
167
  created_at = mapped_column(
158
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
168
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
159
169
  )
160
170
  deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
161
171
 
@@ -177,7 +187,7 @@ class OrganizationInvitation(ModelBase):
177
187
  GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
178
188
  )
179
189
  created_at = mapped_column(
180
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
190
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
181
191
  )
182
192
  last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
183
193
  resend_count: Mapped[int] = mapped_column(default=0)
@@ -185,6 +195,11 @@ class OrganizationInvitation(ModelBase):
185
195
  revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
186
196
 
187
197
 
198
+ # ------------------------ OAuth Provider Accounts -----------------------------
199
+ # MOVED to svc_infra.security.oauth_models for opt-in OAuth support
200
+ # Projects that enable OAuth should import ProviderAccount from there
201
+
202
+
188
203
  # ------------------------ Utilities -------------------------------------------
189
204
 
190
205
 
@@ -238,6 +253,11 @@ __all__ = [
238
253
  "FailedAuthAttempt",
239
254
  "RolePermission",
240
255
  "AuditLog",
256
+ "Organization",
257
+ "Team",
258
+ "OrganizationMembership",
259
+ "OrganizationInvitation",
260
+ # ProviderAccount moved to svc_infra.security.oauth_models (opt-in)
241
261
  "generate_refresh_token",
242
262
  "hash_refresh_token",
243
263
  "compute_audit_hash",
@@ -0,0 +1,59 @@
1
+ """
2
+ OAuth provider account models (opt-in).
3
+
4
+ These models are only registered when a project explicitly enables OAuth.
5
+ Import this module only when enable_oauth=True is passed to add_auth_users.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from typing import TYPE_CHECKING, Optional
13
+
14
+ from sqlalchemy import JSON, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, text
15
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
16
+
17
+ from svc_infra.db.sql.base import ModelBase
18
+ from svc_infra.db.sql.types import GUID
19
+
20
+ if TYPE_CHECKING:
21
+ from svc_infra.security.models import User
22
+
23
+
24
+ class ProviderAccount(ModelBase):
25
+ """OAuth provider account linking (Google, GitHub, etc.)."""
26
+
27
+ __tablename__ = "provider_accounts"
28
+
29
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
30
+ user_id: Mapped[uuid.UUID] = mapped_column(
31
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
32
+ )
33
+ provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
34
+ provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
35
+ access_token: Mapped[Optional[str]] = mapped_column(Text)
36
+ refresh_token: Mapped[Optional[str]] = mapped_column(Text)
37
+ expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
38
+ raw_claims: Mapped[Optional[dict]] = mapped_column(JSON)
39
+
40
+ # Bidirectional relationship to User model
41
+ user: Mapped["User"] = relationship(back_populates="provider_accounts")
42
+
43
+ created_at = mapped_column(
44
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
45
+ )
46
+ updated_at = mapped_column(
47
+ DateTime(timezone=True),
48
+ server_default=text("CURRENT_TIMESTAMP"),
49
+ onupdate=lambda: datetime.now(timezone.utc),
50
+ nullable=False,
51
+ )
52
+
53
+ __table_args__ = (
54
+ UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
55
+ Index("ix_provider_accounts_user_provider", "user_id", "provider"),
56
+ )
57
+
58
+
59
+ __all__ = ["ProviderAccount"]
@@ -0,0 +1,93 @@
1
+ """
2
+ Generic file storage system for svc-infra.
3
+
4
+ Provides backend-agnostic file storage with support for multiple providers:
5
+ - Local filesystem (Railway volumes, Render, development)
6
+ - S3-compatible (AWS S3, DigitalOcean Spaces, Wasabi, Backblaze B2, Minio)
7
+ - Google Cloud Storage (coming soon)
8
+ - Cloudinary (coming soon)
9
+ - In-memory (testing)
10
+
11
+ Quick Start:
12
+ >>> from svc_infra.storage import add_storage, easy_storage
13
+ >>> from fastapi import FastAPI
14
+ >>>
15
+ >>> app = FastAPI()
16
+ >>>
17
+ >>> # Auto-detect backend from environment
18
+ >>> storage = add_storage(app)
19
+ >>>
20
+ >>> # Or explicit backend
21
+ >>> backend = easy_storage(backend="s3", bucket="my-uploads")
22
+ >>> storage = add_storage(app, backend)
23
+
24
+ Usage in Routes:
25
+ >>> from svc_infra.storage import get_storage, StorageBackend
26
+ >>> from fastapi import Depends, UploadFile
27
+ >>>
28
+ >>> @router.post("/upload")
29
+ >>> async def upload_file(
30
+ ... file: UploadFile,
31
+ ... storage: StorageBackend = Depends(get_storage),
32
+ ... ):
33
+ ... content = await file.read()
34
+ ... url = await storage.put(
35
+ ... key=f"uploads/{file.filename}",
36
+ ... data=content,
37
+ ... content_type=file.content_type or "application/octet-stream",
38
+ ... metadata={"user_id": "user_123"}
39
+ ... )
40
+ ... return {"url": url}
41
+
42
+ Environment Variables:
43
+ STORAGE_BACKEND: Backend type (local, s3, gcs, cloudinary, memory)
44
+
45
+ Local:
46
+ STORAGE_BASE_PATH: Directory for files (default: /data/uploads)
47
+ STORAGE_BASE_URL: URL for file serving (default: http://localhost:8000/files)
48
+
49
+ S3:
50
+ STORAGE_S3_BUCKET: Bucket name (required)
51
+ STORAGE_S3_REGION: AWS region (default: us-east-1)
52
+ STORAGE_S3_ENDPOINT: Custom endpoint for S3-compatible services
53
+ STORAGE_S3_ACCESS_KEY: Access key (falls back to AWS_ACCESS_KEY_ID)
54
+ STORAGE_S3_SECRET_KEY: Secret key (falls back to AWS_SECRET_ACCESS_KEY)
55
+
56
+ See Also:
57
+ - ADR-0012: Generic File Storage System design
58
+ - docs/storage.md: Comprehensive storage guide
59
+ """
60
+
61
+ from .add import add_storage, get_storage, health_check_storage
62
+ from .backends import LocalBackend, MemoryBackend, S3Backend
63
+ from .base import (
64
+ FileNotFoundError,
65
+ InvalidKeyError,
66
+ PermissionDeniedError,
67
+ QuotaExceededError,
68
+ StorageBackend,
69
+ StorageError,
70
+ )
71
+ from .easy import easy_storage
72
+ from .settings import StorageSettings
73
+
74
+ __all__ = [
75
+ # Main API
76
+ "add_storage",
77
+ "easy_storage",
78
+ "get_storage",
79
+ "health_check_storage",
80
+ # Base types
81
+ "StorageBackend",
82
+ "StorageSettings",
83
+ # Backends
84
+ "LocalBackend",
85
+ "MemoryBackend",
86
+ "S3Backend",
87
+ # Exceptions
88
+ "StorageError",
89
+ "FileNotFoundError",
90
+ "PermissionDeniedError",
91
+ "QuotaExceededError",
92
+ "InvalidKeyError",
93
+ ]
@@ -0,0 +1,250 @@
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
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("content_type", "application/octet-stream"),
149
+ headers={
150
+ "Content-Disposition": content_disposition,
151
+ "Content-Length": str(len(data)),
152
+ },
153
+ )
154
+
155
+ except FileNotFoundError:
156
+ raise HTTPException(status_code=404, detail="File not found")
157
+ except PermissionDeniedError:
158
+ raise HTTPException(status_code=403, detail="Permission denied")
159
+ except StorageError as e:
160
+ logger.error(f"Storage error serving file {key}: {e}")
161
+ raise HTTPException(status_code=500, detail="Storage error")
162
+
163
+ logger.info(f"File serving enabled at {file_route_prefix}")
164
+
165
+ return backend
166
+
167
+
168
+ def get_storage(request: Request) -> StorageBackend:
169
+ """
170
+ FastAPI dependency to inject storage backend.
171
+
172
+ Use this in route handlers to access the storage backend.
173
+
174
+ Example:
175
+ >>> from fastapi import APIRouter, Depends, UploadFile
176
+ >>> from svc_infra.storage import get_storage, StorageBackend
177
+ >>>
178
+ >>> router = APIRouter()
179
+ >>>
180
+ >>> @router.post("/upload")
181
+ >>> async def upload_file(
182
+ ... file: UploadFile,
183
+ ... storage: StorageBackend = Depends(get_storage),
184
+ ... ):
185
+ ... content = await file.read()
186
+ ... url = await storage.put(
187
+ ... key=f"uploads/{file.filename}",
188
+ ... data=content,
189
+ ... content_type=file.content_type or "application/octet-stream"
190
+ ... )
191
+ ... return {"url": url}
192
+
193
+ Raises:
194
+ RuntimeError: If storage not initialized with add_storage()
195
+ """
196
+ if not hasattr(request.app.state, "storage"):
197
+ raise RuntimeError(
198
+ "Storage not initialized. " "Call add_storage(app) during application setup."
199
+ )
200
+
201
+ return request.app.state.storage
202
+
203
+
204
+ async def health_check_storage(request: Request) -> dict:
205
+ """
206
+ Health check for storage backend.
207
+
208
+ Returns storage status and basic statistics.
209
+
210
+ Example:
211
+ >>> from fastapi import FastAPI
212
+ >>> from svc_infra.storage import add_storage, health_check_storage
213
+ >>>
214
+ >>> app = FastAPI()
215
+ >>> add_storage(app)
216
+ >>>
217
+ >>> @app.get("/_health/storage")
218
+ >>> async def storage_health(request: Request):
219
+ ... return await health_check_storage(request)
220
+
221
+ Returns:
222
+ Dict with status and backend information
223
+ """
224
+ try:
225
+ storage = get_storage(request)
226
+
227
+ # Get backend type
228
+ backend_type = storage.__class__.__name__.replace("Backend", "").lower()
229
+
230
+ # Try a simple operation
231
+ await storage.list_keys(limit=1)
232
+
233
+ return {
234
+ "status": "healthy",
235
+ "backend": backend_type,
236
+ }
237
+
238
+ except Exception as e:
239
+ logger.error(f"Storage health check failed: {e}")
240
+ return {
241
+ "status": "unhealthy",
242
+ "error": str(e),
243
+ }
244
+
245
+
246
+ __all__ = [
247
+ "add_storage",
248
+ "get_storage",
249
+ "health_check_storage",
250
+ ]
@@ -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
+ ]