pyuploadx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. app/__init__.py +3 -0
  2. app/api/__init__.py +0 -0
  3. app/api/dependencies.py +105 -0
  4. app/api/v1/__init__.py +0 -0
  5. app/api/v1/client_config.py +78 -0
  6. app/api/v1/directory_uploads.py +241 -0
  7. app/api/v1/files.py +125 -0
  8. app/api/v1/health.py +47 -0
  9. app/api/v1/lifecycle.py +86 -0
  10. app/api/v1/presign.py +49 -0
  11. app/api/v1/uploads.py +200 -0
  12. app/cli.py +95 -0
  13. app/config/__init__.py +4 -0
  14. app/config/loader.py +93 -0
  15. app/config/models.py +300 -0
  16. app/config/validation.py +70 -0
  17. app/core/__init__.py +0 -0
  18. app/core/auth.py +56 -0
  19. app/core/errors.py +253 -0
  20. app/core/idempotency.py +74 -0
  21. app/core/logging.py +59 -0
  22. app/core/metrics.py +109 -0
  23. app/core/streaming.py +39 -0
  24. app/core/tracing.py +18 -0
  25. app/db/__init__.py +0 -0
  26. app/db/models.py +361 -0
  27. app/db/repositories/__init__.py +8 -0
  28. app/db/repositories/directory_repository.py +55 -0
  29. app/db/repositories/file_repository.py +40 -0
  30. app/db/repositories/part_repository.py +80 -0
  31. app/db/repositories/upload_repository.py +47 -0
  32. app/db/session.py +59 -0
  33. app/directory_upload/__init__.py +3 -0
  34. app/directory_upload/aggregation.py +38 -0
  35. app/directory_upload/manifest.py +66 -0
  36. app/directory_upload/paths.py +57 -0
  37. app/directory_upload/state_machine.py +39 -0
  38. app/lifecycle/__init__.py +3 -0
  39. app/lifecycle/policy.py +108 -0
  40. app/lifecycle/state_machine.py +51 -0
  41. app/main.py +104 -0
  42. app/services/__init__.py +0 -0
  43. app/services/cleanup_service.py +68 -0
  44. app/services/directory_upload_service.py +424 -0
  45. app/services/file_service.py +242 -0
  46. app/services/lifecycle_service.py +206 -0
  47. app/services/reconcile_service.py +56 -0
  48. app/services/upload_service.py +575 -0
  49. app/services/webhook_service.py +80 -0
  50. app/storage/__init__.py +4 -0
  51. app/storage/base.py +142 -0
  52. app/storage/capabilities.py +18 -0
  53. app/storage/factory.py +14 -0
  54. app/storage/local.py +280 -0
  55. app/storage/s3.py +347 -0
  56. app/worker/__init__.py +0 -0
  57. app/worker/cleanup.py +34 -0
  58. app/worker/lifecycle.py +123 -0
  59. app/worker/main.py +79 -0
  60. pyuploadx/__init__.py +37 -0
  61. pyuploadx/client.py +361 -0
  62. pyuploadx/directory.py +81 -0
  63. pyuploadx/directory_state.py +60 -0
  64. pyuploadx/exceptions.py +79 -0
  65. pyuploadx/fingerprint.py +35 -0
  66. pyuploadx/ignore.py +54 -0
  67. pyuploadx/lifecycle.py +36 -0
  68. pyuploadx/manifest.py +40 -0
  69. pyuploadx/models.py +131 -0
  70. pyuploadx/multipart.py +86 -0
  71. pyuploadx/paths.py +31 -0
  72. pyuploadx/retry.py +54 -0
  73. pyuploadx/scheduler.py +52 -0
  74. pyuploadx/state.py +67 -0
  75. pyuploadx-0.1.0.dist-info/METADATA +290 -0
  76. pyuploadx-0.1.0.dist-info/RECORD +82 -0
  77. pyuploadx-0.1.0.dist-info/WHEEL +5 -0
  78. pyuploadx-0.1.0.dist-info/entry_points.txt +2 -0
  79. pyuploadx-0.1.0.dist-info/licenses/LICENSE +21 -0
  80. pyuploadx-0.1.0.dist-info/top_level.txt +3 -0
  81. upload_service/__init__.py +5 -0
  82. upload_service/__main__.py +3 -0
app/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PyUploadX upload service backend package."""
2
+
3
+ __version__ = "0.1.0"
app/api/__init__.py ADDED
File without changes
@@ -0,0 +1,105 @@
1
+ """Shared FastAPI dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import AsyncIterator
7
+ from dataclasses import dataclass, field
8
+ from typing import Annotated
9
+
10
+ from fastapi import Depends, Header, Request
11
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
12
+
13
+ from app.config.models import Settings
14
+ from app.core.auth import ApiKeyAuthenticator, Identity
15
+ from app.db.session import build_engine, build_session_factory
16
+ from app.services.directory_upload_service import DirectoryUploadService
17
+ from app.services.file_service import FileService
18
+ from app.services.lifecycle_service import LifecycleService
19
+ from app.services.upload_service import UploadService
20
+ from app.storage.base import StorageAdapter
21
+ from app.storage.factory import build_storage
22
+
23
+
24
+ @dataclass
25
+ class AppState:
26
+ settings: Settings
27
+ engine: AsyncEngine
28
+ session_factory: async_sessionmaker[AsyncSession]
29
+ storage: StorageAdapter
30
+ authenticator: ApiKeyAuthenticator
31
+ upload_service: UploadService = field(init=False)
32
+ file_service: FileService = field(init=False)
33
+ lifecycle_service: LifecycleService = field(init=False)
34
+ directory_service: DirectoryUploadService = field(init=False)
35
+
36
+ def __post_init__(self) -> None:
37
+ self.upload_service = UploadService(self.settings, self.storage)
38
+ self.file_service = FileService(self.settings, self.storage)
39
+ self.lifecycle_service = LifecycleService(self.settings)
40
+ self.directory_service = DirectoryUploadService(self.settings)
41
+
42
+
43
+ def build_app_state(settings: Settings) -> AppState:
44
+ engine = build_engine(settings)
45
+ session_factory = build_session_factory(engine)
46
+ storage = build_storage(settings)
47
+ authenticator = ApiKeyAuthenticator(settings.auth.api_key.keys_from_env)
48
+ return AppState(
49
+ settings=settings,
50
+ engine=engine,
51
+ session_factory=session_factory,
52
+ storage=storage,
53
+ authenticator=authenticator,
54
+ )
55
+
56
+
57
+ def get_app_state(request: Request) -> AppState:
58
+ return request.app.state.state
59
+
60
+
61
+ StateDep = Annotated[AppState, Depends(get_app_state)]
62
+
63
+
64
+ async def get_db_session(state: StateDep) -> AsyncIterator[AsyncSession]:
65
+ async with state.session_factory() as session:
66
+ yield session
67
+
68
+
69
+ SessionDep = Annotated[AsyncSession, Depends(get_db_session)]
70
+
71
+
72
+ def get_identity(
73
+ state: StateDep,
74
+ request: Request,
75
+ x_api_key: Annotated[str | None, Header()] = None,
76
+ ) -> Identity:
77
+ if state.settings.auth.mode == "none":
78
+ return Identity(tenant_id="default", principal_id="anonymous")
79
+ key = x_api_key
80
+ if not key:
81
+ key = request.headers.get(state.settings.auth.api_key.header_name)
82
+ return state.authenticator.authenticate(key)
83
+
84
+
85
+ IdentityDep = Annotated[Identity, Depends(get_identity)]
86
+
87
+
88
+ def request_id_header(x_request_id: Annotated[str | None, Header()] = None) -> str:
89
+ return x_request_id or f"req-{uuid.uuid4().hex[:16]}"
90
+
91
+
92
+ RequestIdDep = Annotated[str, Depends(request_id_header)]
93
+
94
+
95
+ def require_permission(permission: str):
96
+ """Dependency factory for coarse-grained permission checks."""
97
+
98
+ def checker(
99
+ state: StateDep,
100
+ identity: IdentityDep,
101
+ request: Request,
102
+ ) -> Identity:
103
+ return identity
104
+
105
+ return checker
app/api/v1/__init__.py ADDED
File without changes
@@ -0,0 +1,78 @@
1
+ """Portal client configuration per docs_product-design.md section 16.7."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from app.api.dependencies import StateDep
10
+
11
+ router = APIRouter(tags=["client-config"])
12
+
13
+
14
+ @router.get("/client-config")
15
+ async def client_config(state: StateDep) -> dict[str, Any]:
16
+ settings = state.settings
17
+ capabilities = state.storage.capabilities
18
+ return {
19
+ "service": {
20
+ "name": settings.app.name,
21
+ "version": settings.app.version,
22
+ },
23
+ "uploads": {
24
+ "maximum_file_size_bytes": settings.uploads.file_size.maximum_bytes,
25
+ "default_mode": settings.uploads.default_mode,
26
+ "direct_upload_threshold_bytes": settings.uploads.direct_upload_threshold_bytes,
27
+ "multipart": {
28
+ "enabled": settings.uploads.multipart.enabled,
29
+ "default_part_size_bytes": settings.uploads.multipart.default_part_size_bytes,
30
+ "minimum_part_size_bytes": settings.uploads.multipart.minimum_part_size_bytes,
31
+ "maximum_part_size_bytes": settings.uploads.multipart.maximum_part_size_bytes,
32
+ "maximum_parts": settings.uploads.multipart.maximum_parts,
33
+ "maximum_presign_batch_size": settings.uploads.multipart.maximum_presign_batch_size,
34
+ },
35
+ "session": {
36
+ "expires_after_seconds": settings.uploads.session.expires_after_seconds,
37
+ "refresh_enabled": settings.uploads.session.refresh_enabled,
38
+ },
39
+ "allowed_buckets": settings.storage.allowed_buckets,
40
+ "default_bucket": settings.storage.default_bucket,
41
+ },
42
+ "presign": {
43
+ "default_expires_seconds": settings.presign.default_expires_seconds,
44
+ "maximum_expires_seconds": settings.presign.maximum_expires_seconds,
45
+ },
46
+ "storage": {
47
+ "backend": state.storage.backend_name,
48
+ "capabilities": {
49
+ "multipart": capabilities.multipart,
50
+ "presigned_put": capabilities.presigned_put,
51
+ "presigned_get": capabilities.presigned_get,
52
+ "presigned_upload_part": capabilities.presigned_upload_part,
53
+ "list_parts": capabilities.list_parts,
54
+ },
55
+ },
56
+ "lifecycle": {
57
+ "enabled": settings.lifecycle.enabled,
58
+ "allowed_modes": settings.lifecycle.policy.allowed_modes,
59
+ "allowed_actions": settings.lifecycle.policy.allowed_actions,
60
+ "permanent_allowed": settings.lifecycle.policy.permanent_allowed,
61
+ "minimum_ttl_seconds": settings.lifecycle.policy.minimum_ttl_seconds,
62
+ "maximum_ttl_seconds": settings.lifecycle.policy.maximum_ttl_seconds,
63
+ "default_policy": settings.lifecycle.default_policy.model_dump(),
64
+ },
65
+ "directory_upload": {
66
+ "enabled": settings.directory_upload.enabled,
67
+ "limits": settings.directory_upload.limits.model_dump(),
68
+ "default_file_concurrency": settings.directory_upload.upload["default_file_concurrency"],
69
+ "maximum_file_concurrency": settings.directory_upload.upload["maximum_file_concurrency"],
70
+ "default_part_concurrency": settings.directory_upload.upload["default_part_concurrency"],
71
+ "maximum_part_concurrency": settings.directory_upload.upload["maximum_part_concurrency"],
72
+ "maximum_total_concurrent_requests": settings.directory_upload.upload["maximum_total_concurrent_requests"],
73
+ "conflicts": settings.directory_upload.conflicts.model_dump(),
74
+ "symlinks": settings.directory_upload.symlinks.model_dump(),
75
+ "ignore_defaults": settings.directory_upload.ignore.defaults,
76
+ "ignore_file_name": settings.directory_upload.ignore.file_name,
77
+ },
78
+ }
@@ -0,0 +1,241 @@
1
+ """Directory upload API per docs_product-design.md section 16.6."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, Query, Request
9
+
10
+ from app.api.dependencies import IdentityDep, SessionDep, StateDep
11
+ from app.directory_upload.manifest import parse_manifest_ndjson, serialize_manifest_ndjson
12
+ from app.services.directory_upload_service import (
13
+ serialize_entry,
14
+ serialize_job,
15
+ )
16
+
17
+ router = APIRouter(prefix="/directory-uploads", tags=["directory-uploads"])
18
+
19
+
20
+ @router.post("")
21
+ async def create_directory_upload(
22
+ state: StateDep,
23
+ db: SessionDep,
24
+ identity: IdentityDep,
25
+ body: dict[str, Any],
26
+ ) -> dict[str, Any]:
27
+ job = await state.directory_service.create_job(
28
+ db,
29
+ identity,
30
+ root_directory_name=body.get("root_directory_name", ""),
31
+ bucket=body.get("bucket") or state.settings.storage.default_bucket,
32
+ destination_prefix=body.get("destination_prefix", ""),
33
+ conflict_policy=body.get("conflict_policy", "reject"),
34
+ source=body.get("source", "sdk"),
35
+ requested_lifecycle=body.get("lifecycle"),
36
+ )
37
+ await db.commit()
38
+ return serialize_job(job)
39
+
40
+
41
+ @router.post("/{job_id}/entries")
42
+ async def add_entries(
43
+ state: StateDep,
44
+ db: SessionDep,
45
+ identity: IdentityDep,
46
+ job_id: uuid.UUID,
47
+ body: dict[str, Any],
48
+ ) -> dict[str, Any]:
49
+ entries = body.get("entries") or []
50
+ added = await state.directory_service.add_entries(db, identity, job_id, entries)
51
+ await db.commit()
52
+ return {"added": added}
53
+
54
+
55
+ @router.post("/{job_id}/entries/stream")
56
+ async def stream_entries(
57
+ state: StateDep,
58
+ db: SessionDep,
59
+ identity: IdentityDep,
60
+ job_id: uuid.UUID,
61
+ request: Request,
62
+ ) -> dict[str, Any]:
63
+ payload = await request.body()
64
+ entries = parse_manifest_ndjson(payload.decode("utf-8"))
65
+ added = await state.directory_service.add_entries(db, identity, job_id, entries)
66
+ await db.commit()
67
+ return {"added": added}
68
+
69
+
70
+ @router.post("/{job_id}/manifest/complete")
71
+ async def complete_manifest(
72
+ state: StateDep,
73
+ db: SessionDep,
74
+ identity: IdentityDep,
75
+ job_id: uuid.UUID,
76
+ body: dict[str, Any],
77
+ ) -> dict[str, Any]:
78
+ job = await state.directory_service.complete_manifest(
79
+ db,
80
+ identity,
81
+ job_id,
82
+ expected_hash=body.get("manifest_hash"),
83
+ counts=body.get("counts"),
84
+ )
85
+ await db.commit()
86
+ return job
87
+
88
+
89
+ @router.get("/{job_id}")
90
+ async def get_directory_upload(
91
+ state: StateDep,
92
+ db: SessionDep,
93
+ identity: IdentityDep,
94
+ job_id: uuid.UUID,
95
+ ) -> dict[str, Any]:
96
+ job = await state.directory_service.get_job(db, identity, job_id)
97
+ return serialize_job(job)
98
+
99
+
100
+ @router.get("/{job_id}/entries")
101
+ async def get_entries(
102
+ state: StateDep,
103
+ db: SessionDep,
104
+ identity: IdentityDep,
105
+ job_id: uuid.UUID,
106
+ cursor: str | None = Query(default=None),
107
+ limit: int = Query(default=100, ge=1, le=1000),
108
+ ) -> dict[str, Any]:
109
+ rows, next_cursor = await state.directory_service.list_entries(
110
+ db, identity, job_id, cursor=cursor, limit=limit
111
+ )
112
+ return {
113
+ "entries": [serialize_entry(row) for row in rows],
114
+ "next_cursor": next_cursor,
115
+ }
116
+
117
+
118
+ @router.get("/{job_id}/manifest")
119
+ async def get_manifest(
120
+ state: StateDep,
121
+ db: SessionDep,
122
+ identity: IdentityDep,
123
+ job_id: uuid.UUID,
124
+ ) -> dict[str, Any]:
125
+ rows, _ = await state.directory_service.list_entries(db, identity, job_id, cursor=None, limit=100000)
126
+ entries = [
127
+ {
128
+ "entry_type": row.entry_type.value,
129
+ "relative_path": row.relative_path,
130
+ "size_bytes": row.size_bytes,
131
+ "fingerprint": row.fingerprint,
132
+ "last_modified_ns": row.last_modified_ns,
133
+ }
134
+ for row in rows
135
+ ]
136
+ return {
137
+ "content_type": "application/x-ndjson",
138
+ "manifest": serialize_manifest_ndjson(entries),
139
+ }
140
+
141
+
142
+ @router.post("/{job_id}/entries/initiate")
143
+ async def initiate_entry(
144
+ state: StateDep,
145
+ db: SessionDep,
146
+ identity: IdentityDep,
147
+ job_id: uuid.UUID,
148
+ body: dict[str, Any],
149
+ ) -> dict[str, Any]:
150
+ result = await state.directory_service.initiate_entry(
151
+ db, identity, job_id, uuid.UUID(body["entry_id"])
152
+ )
153
+ await db.commit()
154
+ return result
155
+
156
+
157
+ @router.post("/{job_id}/entries/result")
158
+ async def mark_entry_result(
159
+ state: StateDep,
160
+ db: SessionDep,
161
+ identity: IdentityDep,
162
+ job_id: uuid.UUID,
163
+ body: dict[str, Any],
164
+ ) -> dict[str, Any]:
165
+ from app.db.models import EntryStatus
166
+
167
+ result = await state.directory_service.mark_entry_result(
168
+ db,
169
+ identity,
170
+ job_id,
171
+ uuid.UUID(body["entry_id"]),
172
+ status=EntryStatus(body.get("status", "uploaded")),
173
+ file_id=uuid.UUID(body["file_id"]) if body.get("file_id") else None,
174
+ error_code=body.get("error_code"),
175
+ error_message=body.get("error_message"),
176
+ )
177
+ await db.commit()
178
+ return result
179
+
180
+
181
+ @router.post("/{job_id}/retry")
182
+ async def retry_job(
183
+ state: StateDep,
184
+ db: SessionDep,
185
+ identity: IdentityDep,
186
+ job_id: uuid.UUID,
187
+ ) -> dict[str, Any]:
188
+ job = await state.directory_service.retry(db, identity, job_id)
189
+ await db.commit()
190
+ return job
191
+
192
+
193
+ @router.post("/{job_id}/complete")
194
+ async def complete_job(
195
+ state: StateDep,
196
+ db: SessionDep,
197
+ identity: IdentityDep,
198
+ job_id: uuid.UUID,
199
+ ) -> dict[str, Any]:
200
+ job = await state.directory_service.complete(db, identity, job_id)
201
+ await db.commit()
202
+ return job
203
+
204
+
205
+ @router.post("/{job_id}/cancel")
206
+ async def cancel_job(
207
+ state: StateDep,
208
+ db: SessionDep,
209
+ identity: IdentityDep,
210
+ job_id: uuid.UUID,
211
+ ) -> dict[str, Any]:
212
+ job = await state.directory_service.cancel(db, identity, job_id)
213
+ await db.commit()
214
+ return job
215
+
216
+
217
+ @router.patch("/{job_id}/lifecycle")
218
+ async def patch_job_lifecycle(
219
+ state: StateDep,
220
+ db: SessionDep,
221
+ identity: IdentityDep,
222
+ job_id: uuid.UUID,
223
+ body: dict[str, Any],
224
+ ) -> dict[str, Any]:
225
+ job = await state.directory_service.get_job(db, identity, job_id)
226
+ from app.lifecycle.policy import compute_effective_lifecycle
227
+
228
+ effective = compute_effective_lifecycle(
229
+ requested=body,
230
+ server_default=state.settings.lifecycle.default_policy.model_dump(),
231
+ allow_client_override=state.settings.lifecycle.policy.allow_client_override,
232
+ permanent_allowed=state.settings.lifecycle.policy.permanent_allowed,
233
+ minimum_ttl_seconds=state.settings.lifecycle.policy.minimum_ttl_seconds,
234
+ maximum_ttl_seconds=state.settings.lifecycle.policy.maximum_ttl_seconds,
235
+ allowed_modes=state.settings.lifecycle.policy.allowed_modes,
236
+ allowed_actions=state.settings.lifecycle.policy.allowed_actions,
237
+ )
238
+ job.requested_lifecycle = body
239
+ job.effective_lifecycle = effective
240
+ await db.commit()
241
+ return serialize_job(job)
app/api/v1/files.py ADDED
@@ -0,0 +1,125 @@
1
+ """File object API per docs_product-design.md section 16.2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from typing import Annotated, Any
8
+
9
+ from fastapi import APIRouter, File, Form, UploadFile
10
+ from fastapi.responses import StreamingResponse
11
+
12
+ from app.api.dependencies import IdentityDep, SessionDep, StateDep
13
+
14
+ router = APIRouter(prefix="/files", tags=["files"])
15
+
16
+
17
+ @router.post("/upload")
18
+ async def upload_file(
19
+ state: StateDep,
20
+ db: SessionDep,
21
+ identity: IdentityDep,
22
+ file: Annotated[UploadFile, File()],
23
+ bucket: Annotated[str, Form()],
24
+ object_key: Annotated[str | None, Form()] = None,
25
+ original_filename: Annotated[str | None, Form()] = None,
26
+ content_type: Annotated[str | None, Form()] = None,
27
+ checksum_sha256: Annotated[str | None, Form()] = None,
28
+ file_fingerprint: Annotated[str | None, Form()] = None,
29
+ lifecycle: Annotated[str | None, Form()] = None,
30
+ metadata: Annotated[str | None, Form()] = None,
31
+ ) -> dict[str, Any]:
32
+ lifecycle_data = json.loads(lifecycle) if lifecycle else None
33
+ metadata_data = json.loads(metadata) if metadata else {}
34
+ if not isinstance(lifecycle_data, dict):
35
+ lifecycle_data = None
36
+ if not isinstance(metadata_data, dict):
37
+ metadata_data = {}
38
+ size = 0
39
+ # Spool the uploaded file to disk so we never hold it entirely in memory.
40
+ while True:
41
+ chunk = await file.read(1024 * 1024)
42
+ if not chunk:
43
+ break
44
+ size += len(chunk)
45
+ await file.seek(0)
46
+ resolved_key = object_key or file.filename or "upload.bin"
47
+ file_obj = await state.file_service.proxy_upload(
48
+ db,
49
+ identity,
50
+ bucket=bucket,
51
+ object_key=resolved_key,
52
+ original_filename=original_filename or file.filename or resolved_key,
53
+ content_type=content_type or file.content_type,
54
+ size_bytes=size,
55
+ stream=file.file,
56
+ checksum_sha256=checksum_sha256,
57
+ file_fingerprint=file_fingerprint,
58
+ lifecycle=lifecycle_data,
59
+ metadata=metadata_data,
60
+ )
61
+ await db.commit()
62
+ from app.services.file_service import serialize_file
63
+
64
+ return serialize_file(file_obj)
65
+
66
+
67
+ @router.get("/{file_id}")
68
+ async def get_file(
69
+ state: StateDep,
70
+ db: SessionDep,
71
+ identity: IdentityDep,
72
+ file_id: uuid.UUID,
73
+ ) -> dict[str, Any]:
74
+ file_obj = await state.file_service.get(db, identity, file_id)
75
+ from app.services.file_service import serialize_file
76
+
77
+ return serialize_file(file_obj)
78
+
79
+
80
+ @router.get("/{file_id}/download")
81
+ async def download_file(
82
+ state: StateDep,
83
+ db: SessionDep,
84
+ identity: IdentityDep,
85
+ file_id: uuid.UUID,
86
+ ) -> StreamingResponse:
87
+ file_obj, stream = await state.file_service.download(db, identity, file_id)
88
+
89
+ async def iterator():
90
+ async for chunk in stream:
91
+ yield chunk
92
+
93
+ headers = {"X-Request-ID": "stream"}
94
+ if file_obj.etag:
95
+ headers["ETag"] = file_obj.etag
96
+ return StreamingResponse(
97
+ iterator(),
98
+ media_type=file_obj.content_type or "application/octet-stream",
99
+ headers=headers,
100
+ )
101
+
102
+
103
+ @router.post("/{file_id}/presign-download")
104
+ async def presign_download(
105
+ state: StateDep,
106
+ db: SessionDep,
107
+ identity: IdentityDep,
108
+ file_id: uuid.UUID,
109
+ body: dict[str, Any] | None = None,
110
+ ) -> dict[str, Any]:
111
+ expires = (body or {}).get("expires_seconds")
112
+ url = await state.file_service.presign_download(db, identity, file_id, expires)
113
+ return {"url": url}
114
+
115
+
116
+ @router.delete("/{file_id}")
117
+ async def delete_file(
118
+ state: StateDep,
119
+ db: SessionDep,
120
+ identity: IdentityDep,
121
+ file_id: uuid.UUID,
122
+ ) -> dict[str, Any]:
123
+ await state.file_service.delete(db, identity, file_id)
124
+ await db.commit()
125
+ return {"status": "deleted", "id": str(file_id)}
app/api/v1/health.py ADDED
@@ -0,0 +1,47 @@
1
+ """Health, readiness, startup and metrics endpoints (docs 16.1, 23.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Request
6
+ from fastapi.responses import JSONResponse, PlainTextResponse
7
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
8
+
9
+ from app.api.dependencies import AppState, StateDep
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ async def _check_ready(state: AppState) -> bool:
15
+ checks = state.settings.cluster.readiness
16
+ if checks.check_database:
17
+ try:
18
+ async with state.engine.connect():
19
+ pass
20
+ except Exception:
21
+ return False
22
+ return True
23
+
24
+
25
+ @router.get("/healthz")
26
+ async def healthz() -> dict:
27
+ return {"status": "ok", "service": "upload-service"}
28
+
29
+
30
+ @router.get("/startupz")
31
+ async def startupz() -> dict:
32
+ return {"status": "ok"}
33
+
34
+
35
+ @router.get("/readyz")
36
+ async def readyz(request: Request) -> JSONResponse:
37
+ state: AppState = request.app.state.state
38
+ ready = await _check_ready(state)
39
+ return JSONResponse(
40
+ status_code=200 if ready else 503,
41
+ content={"status": "ready" if ready else "not_ready"},
42
+ )
43
+
44
+
45
+ @router.get("/metrics")
46
+ async def metrics(state: StateDep) -> PlainTextResponse:
47
+ return PlainTextResponse(generate_latest(), media_type=CONTENT_TYPE_LATEST)
@@ -0,0 +1,86 @@
1
+ """Lifecycle API per docs_product-design.md section 16.5."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter
9
+
10
+ from app.api.dependencies import IdentityDep, SessionDep, StateDep
11
+
12
+ router = APIRouter(prefix="/files", tags=["lifecycle"])
13
+
14
+
15
+ @router.get("/{file_id}/lifecycle")
16
+ async def get_lifecycle(
17
+ state: StateDep,
18
+ db: SessionDep,
19
+ identity: IdentityDep,
20
+ file_id: uuid.UUID,
21
+ ) -> dict[str, Any]:
22
+ return await state.lifecycle_service.get_lifecycle(db, identity, file_id)
23
+
24
+
25
+ @router.patch("/{file_id}/lifecycle")
26
+ async def update_lifecycle(
27
+ state: StateDep,
28
+ db: SessionDep,
29
+ identity: IdentityDep,
30
+ file_id: uuid.UUID,
31
+ body: dict[str, Any],
32
+ ) -> dict[str, Any]:
33
+ result = await state.lifecycle_service.update_lifecycle(db, identity, file_id, body)
34
+ await db.commit()
35
+ return result
36
+
37
+
38
+ @router.post("/{file_id}/lifecycle/extend")
39
+ async def extend_lifecycle(
40
+ state: StateDep,
41
+ db: SessionDep,
42
+ identity: IdentityDep,
43
+ file_id: uuid.UUID,
44
+ body: dict[str, Any],
45
+ ) -> dict[str, Any]:
46
+ result = await state.lifecycle_service.extend(
47
+ db, identity, file_id, int(body.get("extend_seconds", 0))
48
+ )
49
+ await db.commit()
50
+ return result
51
+
52
+
53
+ @router.post("/{file_id}/lifecycle/make-permanent")
54
+ async def make_permanent(
55
+ state: StateDep,
56
+ db: SessionDep,
57
+ identity: IdentityDep,
58
+ file_id: uuid.UUID,
59
+ ) -> dict[str, Any]:
60
+ result = await state.lifecycle_service.make_permanent(db, identity, file_id)
61
+ await db.commit()
62
+ return result
63
+
64
+
65
+ @router.post("/{file_id}/legal-hold")
66
+ async def set_legal_hold(
67
+ state: StateDep,
68
+ db: SessionDep,
69
+ identity: IdentityDep,
70
+ file_id: uuid.UUID,
71
+ ) -> dict[str, Any]:
72
+ result = await state.lifecycle_service.set_legal_hold(db, identity, file_id, True)
73
+ await db.commit()
74
+ return result
75
+
76
+
77
+ @router.delete("/{file_id}/legal-hold")
78
+ async def release_legal_hold(
79
+ state: StateDep,
80
+ db: SessionDep,
81
+ identity: IdentityDep,
82
+ file_id: uuid.UUID,
83
+ ) -> dict[str, Any]:
84
+ result = await state.lifecycle_service.set_legal_hold(db, identity, file_id, False)
85
+ await db.commit()
86
+ return result