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.
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +8 -5
- svc_infra/api/fastapi/db/sql/crud_router.py +4 -4
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/setup.py +10 -12
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +25 -11
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +20 -5
- svc_infra/docs/acceptance-matrix.md +17 -0
- svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +127 -0
- svc_infra/docs/storage.md +982 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/METADATA +8 -3
- {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/RECORD +33 -19
- {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {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
|
svc_infra/security/models.py
CHANGED
|
@@ -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
|
|
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
|
+
]
|
svc_infra/storage/add.py
ADDED
|
@@ -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
|
+
]
|