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.
- app/__init__.py +3 -0
- app/api/__init__.py +0 -0
- app/api/dependencies.py +105 -0
- app/api/v1/__init__.py +0 -0
- app/api/v1/client_config.py +78 -0
- app/api/v1/directory_uploads.py +241 -0
- app/api/v1/files.py +125 -0
- app/api/v1/health.py +47 -0
- app/api/v1/lifecycle.py +86 -0
- app/api/v1/presign.py +49 -0
- app/api/v1/uploads.py +200 -0
- app/cli.py +95 -0
- app/config/__init__.py +4 -0
- app/config/loader.py +93 -0
- app/config/models.py +300 -0
- app/config/validation.py +70 -0
- app/core/__init__.py +0 -0
- app/core/auth.py +56 -0
- app/core/errors.py +253 -0
- app/core/idempotency.py +74 -0
- app/core/logging.py +59 -0
- app/core/metrics.py +109 -0
- app/core/streaming.py +39 -0
- app/core/tracing.py +18 -0
- app/db/__init__.py +0 -0
- app/db/models.py +361 -0
- app/db/repositories/__init__.py +8 -0
- app/db/repositories/directory_repository.py +55 -0
- app/db/repositories/file_repository.py +40 -0
- app/db/repositories/part_repository.py +80 -0
- app/db/repositories/upload_repository.py +47 -0
- app/db/session.py +59 -0
- app/directory_upload/__init__.py +3 -0
- app/directory_upload/aggregation.py +38 -0
- app/directory_upload/manifest.py +66 -0
- app/directory_upload/paths.py +57 -0
- app/directory_upload/state_machine.py +39 -0
- app/lifecycle/__init__.py +3 -0
- app/lifecycle/policy.py +108 -0
- app/lifecycle/state_machine.py +51 -0
- app/main.py +104 -0
- app/services/__init__.py +0 -0
- app/services/cleanup_service.py +68 -0
- app/services/directory_upload_service.py +424 -0
- app/services/file_service.py +242 -0
- app/services/lifecycle_service.py +206 -0
- app/services/reconcile_service.py +56 -0
- app/services/upload_service.py +575 -0
- app/services/webhook_service.py +80 -0
- app/storage/__init__.py +4 -0
- app/storage/base.py +142 -0
- app/storage/capabilities.py +18 -0
- app/storage/factory.py +14 -0
- app/storage/local.py +280 -0
- app/storage/s3.py +347 -0
- app/worker/__init__.py +0 -0
- app/worker/cleanup.py +34 -0
- app/worker/lifecycle.py +123 -0
- app/worker/main.py +79 -0
- pyuploadx/__init__.py +37 -0
- pyuploadx/client.py +361 -0
- pyuploadx/directory.py +81 -0
- pyuploadx/directory_state.py +60 -0
- pyuploadx/exceptions.py +79 -0
- pyuploadx/fingerprint.py +35 -0
- pyuploadx/ignore.py +54 -0
- pyuploadx/lifecycle.py +36 -0
- pyuploadx/manifest.py +40 -0
- pyuploadx/models.py +131 -0
- pyuploadx/multipart.py +86 -0
- pyuploadx/paths.py +31 -0
- pyuploadx/retry.py +54 -0
- pyuploadx/scheduler.py +52 -0
- pyuploadx/state.py +67 -0
- pyuploadx-0.1.0.dist-info/METADATA +290 -0
- pyuploadx-0.1.0.dist-info/RECORD +82 -0
- pyuploadx-0.1.0.dist-info/WHEEL +5 -0
- pyuploadx-0.1.0.dist-info/entry_points.txt +2 -0
- pyuploadx-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyuploadx-0.1.0.dist-info/top_level.txt +3 -0
- upload_service/__init__.py +5 -0
- upload_service/__main__.py +3 -0
app/__init__.py
ADDED
app/api/__init__.py
ADDED
|
File without changes
|
app/api/dependencies.py
ADDED
|
@@ -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)
|
app/api/v1/lifecycle.py
ADDED
|
@@ -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
|