Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/backend/domains/artifacts/content_hash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Content hashing utility for artifact uploads."""

import hashlib

from fastapi import UploadFile


async def compute_hash(file: UploadFile) -> str:
"""Compute a SHA-256 hash of the uploaded file content.

The file position is reset to 0 after hashing so the file can
be read again by subsequent callers.
"""
sha256 = hashlib.sha256()
await file.seek(0)
while chunk := await file.read(8192):
sha256.update(chunk)
await file.seek(0)
return f"sha256:{sha256.hexdigest()}"
1 change: 1 addition & 0 deletions src/backend/domains/artifacts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class Artifact(BaseModel):
artifact_type: str | None = Field(default=None, alias="artifactType")
status: ArtifactStatus = ArtifactStatus.UPLOADING
file_size_bytes: int | None = Field(default=None, alias="fileSizeBytes")
blob_path: str | None = Field(default=None, alias="blobPath")
content_hash: str | None = Field(default=None, alias="contentHash")
error: ArtifactError | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt")
Expand Down
15 changes: 15 additions & 0 deletions src/backend/domains/artifacts/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ async def update_status(
updated.etag = result.get("_etag")
return updated

async def update(self, artifact: Artifact) -> Artifact:
"""Persist an artifact document (full replace) with optimistic concurrency."""
container = await self._get_container()
artifact.updated_at = datetime.now(UTC)
doc = artifact.model_dump(by_alias=True)
kwargs: dict = {}
if artifact.etag:
kwargs["etag"] = artifact.etag
kwargs["match_condition"] = "IfMatch"
result = await container.replace_item(item=artifact.id, body=doc, **kwargs)
logger.info("artifact_updated", artifact_id=artifact.id)
updated = Artifact.model_validate(result)
updated.etag = result.get("_etag")
return updated

async def soft_delete(self, tenant_id: str, artifact_id: str) -> Artifact | None:
"""Soft-delete an artifact by setting deletedAt timestamp."""
artifact = await self.get_by_id(tenant_id, artifact_id)
Expand Down
126 changes: 124 additions & 2 deletions src/backend/domains/artifacts/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from datetime import UTC, datetime

import structlog
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from fastapi import APIRouter, File, Form, Query, Request, UploadFile
from fastapi.responses import JSONResponse, Response

from shared.models import Meta, PaginatedResponse, PaginationInfo, ResponseEnvelope

Expand All @@ -22,13 +22,91 @@ def _request_id(request: Request) -> str:
return request.headers.get("X-Request-ID", str(uuid.uuid4()))


def _get_tenant(request: Request):
"""Return (tenant, tier) or (None, None) from request state."""
tenant = getattr(request.state, "tenant", None)
tier = getattr(request.state, "tier", None)
return tenant, tier


def _get_tenant_id(request: Request) -> str | None:
tenant = getattr(request.state, "tenant", None)
if tenant is None:
return None
return tenant.id


# ---------------------------------------------------------------------------
# POST — Upload artifact
# ---------------------------------------------------------------------------


@router.post("", status_code=202)
async def upload_artifact(
project_id: str,
request: Request,
file: UploadFile = File(...), # noqa: B008
artifact_type: str | None = Form(default=None), # noqa: B008
):
"""Upload an artifact file (multipart/form-data)."""
req_id = _request_id(request)
tenant, tier = _get_tenant(request)

if tenant is None or tier is None:
return JSONResponse(
status_code=401,
content={
"error": {
"code": "UNAUTHORIZED",
"message": "Tenant context required.",
"request_id": req_id,
}
},
)

try:
artifact = await artifact_service.upload_artifact(
tenant=tenant,
tier=tier,
project_id=project_id,
file=file,
artifact_type_override=artifact_type,
)
except ValueError as exc:
logger.warning(
"File upload rejected due to ValueError",
request_id=req_id,
tenant_id=_get_tenant_id(request),
project_id=project_id,
error=str(exc),
)
return JSONResponse(
status_code=413,
content={
"error": {
"code": "FILE_TOO_LARGE",
"message": "Uploaded file is too large.",
"request_id": req_id,
}
},
Comment thread Fixed
)

artifact_resp = ArtifactResponse.from_artifact(artifact)
envelope = ResponseEnvelope(
data=artifact_resp.model_dump(by_alias=True),
meta=Meta(request_id=req_id, timestamp=datetime.now(UTC)),
)
return JSONResponse(
status_code=202,
content=envelope.model_dump(mode="json"),
)


# ---------------------------------------------------------------------------
# GET — List artifacts
# ---------------------------------------------------------------------------


@router.get("")
async def list_artifacts(
project_id: str,
Expand Down Expand Up @@ -144,3 +222,47 @@ async def delete_artifact(project_id: str, artifact_id: str, request: Request):
)

return None


# ---------------------------------------------------------------------------
# GET — Download artifact file
# ---------------------------------------------------------------------------


@router.get("/{artifact_id}/download")
async def download_artifact(project_id: str, artifact_id: str, request: Request):
"""Download the raw artifact file from Blob Storage."""
req_id = _request_id(request)
tenant_id = _get_tenant_id(request)

if tenant_id is None:
return JSONResponse(
status_code=401,
content={
"error": {
"code": "UNAUTHORIZED",
"message": "Tenant context required.",
"request_id": req_id,
}
},
)

result = await artifact_service.download_artifact(tenant_id, project_id, artifact_id)
if result is None:
return JSONResponse(
status_code=404,
content={
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Artifact not found.",
"request_id": req_id,
}
},
)

content, filename = result
return Response(
content=content,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
111 changes: 110 additions & 1 deletion src/backend/domains/artifacts/service.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,125 @@
"""Artifact domain service — business logic for artifact metadata."""

import uuid

import structlog
from fastapi import UploadFile

from domains.tenants.models import Tenant, TierDefinition
from domains.tenants.repository import tenant_repository
from shared.blob import blob_service
from shared.events import ARTIFACT_UPLOADED, build_cloud_event, event_grid_publisher

from .models import Artifact, ArtifactStatus
from .content_hash import compute_hash
from .models import Artifact, ArtifactStatus, transition_status
from .repository import artifact_repository
from .type_detector import detect_artifact_type

logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)


class ArtifactService:
"""Manages artifact metadata operations."""

async def upload_artifact(
self,
tenant: Tenant,
tier: TierDefinition,
project_id: str,
file: UploadFile,
artifact_type_override: str | None = None,
) -> Artifact:
"""Upload an artifact file, store in Blob Storage, and publish event.

Returns the created artifact metadata document.
Raises ValueError for file size violations.
"""
# --- File size validation ---
max_size = tier.limits.max_file_size_mb * 1024 * 1024
content = await file.read()
file_size = len(content)
await file.seek(0)

if file_size > max_size:
raise ValueError(
f"File size {file_size} exceeds maximum {max_size} bytes "
f"({tier.limits.max_file_size_mb} MB)."
)

# --- Generate artifact ID ---
artifact_id = f"art_{uuid.uuid4().hex[:12]}"

# --- Detect artifact type ---
filename = file.filename or "unknown"
if artifact_type_override:
detected_type = artifact_type_override
else:
detected_type = await detect_artifact_type(filename, file)

# --- Create initial metadata (status: uploading) ---
artifact = Artifact(
id=artifact_id,
partitionKey=tenant.id,
tenantId=tenant.id,
projectId=project_id,
name=filename,
artifactType=detected_type,
status=ArtifactStatus.UPLOADING,
fileSizeBytes=file_size,
)
artifact = await artifact_repository.create(artifact)

# --- Upload to Blob Storage ---
blob_path = f"tenants/{tenant.id}/projects/{project_id}/artifacts/{artifact_id}/{filename}"
await blob_service.upload_blob(blob_path, content, content_type=file.content_type or "application/octet-stream")

# --- Compute content hash ---
content_hash = await compute_hash(file)

# --- Update metadata to uploaded / unsupported ---
target_status = ArtifactStatus.UNSUPPORTED if detected_type == "unknown" else ArtifactStatus.UPLOADED
transition_status(artifact.status, target_status)
artifact.status = target_status
artifact.blob_path = blob_path
artifact.content_hash = content_hash
artifact = await artifact_repository.update(artifact)

# --- Increment tenant usage ---
await tenant_repository.increment_usage(tenant.id, "total_artifact_count")

# --- Publish ArtifactUploaded event ---
if target_status == ArtifactStatus.UPLOADED:
event = build_cloud_event(
event_type=ARTIFACT_UPLOADED,
subject=f"tenants/{tenant.id}/projects/{project_id}/artifacts/{artifact_id}",
data={
"tenantId": tenant.id,
"projectId": project_id,
"artifactId": artifact_id,
"artifactType": detected_type,
"blobPath": blob_path,
"fileSizeBytes": file_size,
"contentHash": content_hash,
},
)
await event_grid_publisher.publish_event(event)

return artifact

async def download_artifact(
self, tenant_id: str, project_id: str, artifact_id: str
) -> tuple[bytes, str] | None:
"""Download artifact file content from Blob Storage.

Returns (content_bytes, filename) or None if artifact not found.
"""
artifact = await self.get_artifact(tenant_id, project_id, artifact_id)
if artifact is None or artifact.blob_path is None:
return None

content = await blob_service.download_blob(artifact.blob_path)
return content, artifact.name

async def get_artifact(
self, tenant_id: str, project_id: str, artifact_id: str
) -> Artifact | None:
Expand Down
Loading
Loading