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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/storage/add.py
ADDED
|
@@ -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,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"]
|