933 lines
32 KiB
Python
933 lines
32 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Header, Response
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import or_, func
|
|
from typing import List, Optional
|
|
import math
|
|
import re
|
|
import io
|
|
import hashlib
|
|
|
|
from .database import get_db
|
|
from .storage import get_storage, S3Storage, MULTIPART_CHUNK_SIZE
|
|
from .models import Project, Package, Artifact, Tag, Upload, Consumer
|
|
from .schemas import (
|
|
ProjectCreate, ProjectResponse,
|
|
PackageCreate, PackageResponse, PackageDetailResponse, TagSummary,
|
|
PACKAGE_FORMATS, PACKAGE_PLATFORMS,
|
|
ArtifactResponse,
|
|
TagCreate, TagResponse,
|
|
UploadResponse,
|
|
ConsumerResponse,
|
|
HealthResponse,
|
|
PaginatedResponse, PaginationMeta,
|
|
ResumableUploadInitRequest,
|
|
ResumableUploadInitResponse,
|
|
ResumableUploadPartResponse,
|
|
ResumableUploadCompleteRequest,
|
|
ResumableUploadCompleteResponse,
|
|
ResumableUploadStatusResponse,
|
|
)
|
|
from .metadata import extract_metadata
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def get_user_id(request: Request) -> str:
|
|
"""Extract user ID from request (simplified for now)"""
|
|
api_key = request.headers.get("X-Orchard-API-Key")
|
|
if api_key:
|
|
return "api-user"
|
|
auth = request.headers.get("Authorization")
|
|
if auth:
|
|
return "bearer-user"
|
|
return "anonymous"
|
|
|
|
|
|
# Health check
|
|
@router.get("/health", response_model=HealthResponse)
|
|
def health_check():
|
|
return HealthResponse(status="ok")
|
|
|
|
|
|
# Project routes
|
|
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse])
|
|
def list_projects(
|
|
request: Request,
|
|
page: int = Query(default=1, ge=1, description="Page number"),
|
|
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
|
|
search: Optional[str] = Query(default=None, description="Search by project name"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
user_id = get_user_id(request)
|
|
|
|
# Base query - filter by access
|
|
query = db.query(Project).filter(
|
|
or_(Project.is_public == True, Project.created_by == user_id)
|
|
)
|
|
|
|
# Apply search filter (case-insensitive)
|
|
if search:
|
|
query = query.filter(func.lower(Project.name).contains(search.lower()))
|
|
|
|
# Get total count before pagination
|
|
total = query.count()
|
|
|
|
# Apply pagination
|
|
offset = (page - 1) * limit
|
|
projects = query.order_by(Project.name).offset(offset).limit(limit).all()
|
|
|
|
# Calculate total pages
|
|
total_pages = math.ceil(total / limit) if total > 0 else 1
|
|
|
|
return PaginatedResponse(
|
|
items=projects,
|
|
pagination=PaginationMeta(
|
|
page=page,
|
|
limit=limit,
|
|
total=total,
|
|
total_pages=total_pages,
|
|
),
|
|
)
|
|
|
|
|
|
@router.post("/api/v1/projects", response_model=ProjectResponse)
|
|
def create_project(project: ProjectCreate, request: Request, db: Session = Depends(get_db)):
|
|
user_id = get_user_id(request)
|
|
|
|
existing = db.query(Project).filter(Project.name == project.name).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Project already exists")
|
|
|
|
db_project = Project(
|
|
name=project.name,
|
|
description=project.description,
|
|
is_public=project.is_public,
|
|
created_by=user_id,
|
|
)
|
|
db.add(db_project)
|
|
db.commit()
|
|
db.refresh(db_project)
|
|
return db_project
|
|
|
|
|
|
@router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse)
|
|
def get_project(project_name: str, db: Session = Depends(get_db)):
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
return project
|
|
|
|
|
|
# Package routes
|
|
@router.get("/api/v1/project/{project_name}/packages", response_model=PaginatedResponse[PackageDetailResponse])
|
|
def list_packages(
|
|
project_name: str,
|
|
page: int = Query(default=1, ge=1, description="Page number"),
|
|
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
|
|
search: Optional[str] = Query(default=None, description="Search by name or description"),
|
|
sort: str = Query(default="name", description="Sort field (name, created_at, updated_at)"),
|
|
order: str = Query(default="asc", description="Sort order (asc, desc)"),
|
|
format: Optional[str] = Query(default=None, description="Filter by package format"),
|
|
platform: Optional[str] = Query(default=None, description="Filter by platform"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
# Validate sort field
|
|
valid_sort_fields = {"name": Package.name, "created_at": Package.created_at, "updated_at": Package.updated_at}
|
|
if sort not in valid_sort_fields:
|
|
raise HTTPException(status_code=400, detail=f"Invalid sort field. Must be one of: {', '.join(valid_sort_fields.keys())}")
|
|
|
|
# Validate order
|
|
if order not in ("asc", "desc"):
|
|
raise HTTPException(status_code=400, detail="Invalid order. Must be 'asc' or 'desc'")
|
|
|
|
# Validate format filter
|
|
if format and format not in PACKAGE_FORMATS:
|
|
raise HTTPException(status_code=400, detail=f"Invalid format. Must be one of: {', '.join(PACKAGE_FORMATS)}")
|
|
|
|
# Validate platform filter
|
|
if platform and platform not in PACKAGE_PLATFORMS:
|
|
raise HTTPException(status_code=400, detail=f"Invalid platform. Must be one of: {', '.join(PACKAGE_PLATFORMS)}")
|
|
|
|
# Base query
|
|
query = db.query(Package).filter(Package.project_id == project.id)
|
|
|
|
# Apply search filter (case-insensitive on name and description)
|
|
if search:
|
|
search_lower = search.lower()
|
|
query = query.filter(
|
|
or_(
|
|
func.lower(Package.name).contains(search_lower),
|
|
func.lower(Package.description).contains(search_lower)
|
|
)
|
|
)
|
|
|
|
# Apply format filter
|
|
if format:
|
|
query = query.filter(Package.format == format)
|
|
|
|
# Apply platform filter
|
|
if platform:
|
|
query = query.filter(Package.platform == platform)
|
|
|
|
# Get total count before pagination
|
|
total = query.count()
|
|
|
|
# Apply sorting
|
|
sort_column = valid_sort_fields[sort]
|
|
if order == "desc":
|
|
query = query.order_by(sort_column.desc())
|
|
else:
|
|
query = query.order_by(sort_column.asc())
|
|
|
|
# Apply pagination
|
|
offset = (page - 1) * limit
|
|
packages = query.offset(offset).limit(limit).all()
|
|
|
|
# Calculate total pages
|
|
total_pages = math.ceil(total / limit) if total > 0 else 1
|
|
|
|
# Build detailed responses with aggregated data
|
|
detailed_packages = []
|
|
for pkg in packages:
|
|
# Get tag count
|
|
tag_count = db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0
|
|
|
|
# Get unique artifact count and total size via uploads
|
|
artifact_stats = db.query(
|
|
func.count(func.distinct(Upload.artifact_id)),
|
|
func.coalesce(func.sum(Artifact.size), 0)
|
|
).join(Artifact, Upload.artifact_id == Artifact.id).filter(
|
|
Upload.package_id == pkg.id
|
|
).first()
|
|
artifact_count = artifact_stats[0] if artifact_stats else 0
|
|
total_size = artifact_stats[1] if artifact_stats else 0
|
|
|
|
# Get latest tag
|
|
latest_tag_obj = db.query(Tag).filter(
|
|
Tag.package_id == pkg.id
|
|
).order_by(Tag.created_at.desc()).first()
|
|
latest_tag = latest_tag_obj.name if latest_tag_obj else None
|
|
|
|
# Get latest upload timestamp
|
|
latest_upload = db.query(func.max(Upload.uploaded_at)).filter(
|
|
Upload.package_id == pkg.id
|
|
).scalar()
|
|
|
|
# Get recent tags (limit 5)
|
|
recent_tags_objs = db.query(Tag).filter(
|
|
Tag.package_id == pkg.id
|
|
).order_by(Tag.created_at.desc()).limit(5).all()
|
|
recent_tags = [
|
|
TagSummary(name=t.name, artifact_id=t.artifact_id, created_at=t.created_at)
|
|
for t in recent_tags_objs
|
|
]
|
|
|
|
detailed_packages.append(PackageDetailResponse(
|
|
id=pkg.id,
|
|
project_id=pkg.project_id,
|
|
name=pkg.name,
|
|
description=pkg.description,
|
|
format=pkg.format,
|
|
platform=pkg.platform,
|
|
created_at=pkg.created_at,
|
|
updated_at=pkg.updated_at,
|
|
tag_count=tag_count,
|
|
artifact_count=artifact_count,
|
|
total_size=total_size,
|
|
latest_tag=latest_tag,
|
|
latest_upload_at=latest_upload,
|
|
recent_tags=recent_tags,
|
|
))
|
|
|
|
return PaginatedResponse(
|
|
items=detailed_packages,
|
|
pagination=PaginationMeta(
|
|
page=page,
|
|
limit=limit,
|
|
total=total,
|
|
total_pages=total_pages,
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/api/v1/project/{project_name}/packages/{package_name}", response_model=PackageDetailResponse)
|
|
def get_package(
|
|
project_name: str,
|
|
package_name: str,
|
|
include_tags: bool = Query(default=False, description="Include all tags (not just recent 5)"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get a single package with full metadata"""
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
pkg = db.query(Package).filter(
|
|
Package.project_id == project.id,
|
|
Package.name == package_name
|
|
).first()
|
|
if not pkg:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
# Get tag count
|
|
tag_count = db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0
|
|
|
|
# Get unique artifact count and total size via uploads
|
|
artifact_stats = db.query(
|
|
func.count(func.distinct(Upload.artifact_id)),
|
|
func.coalesce(func.sum(Artifact.size), 0)
|
|
).join(Artifact, Upload.artifact_id == Artifact.id).filter(
|
|
Upload.package_id == pkg.id
|
|
).first()
|
|
artifact_count = artifact_stats[0] if artifact_stats else 0
|
|
total_size = artifact_stats[1] if artifact_stats else 0
|
|
|
|
# Get latest tag
|
|
latest_tag_obj = db.query(Tag).filter(
|
|
Tag.package_id == pkg.id
|
|
).order_by(Tag.created_at.desc()).first()
|
|
latest_tag = latest_tag_obj.name if latest_tag_obj else None
|
|
|
|
# Get latest upload timestamp
|
|
latest_upload = db.query(func.max(Upload.uploaded_at)).filter(
|
|
Upload.package_id == pkg.id
|
|
).scalar()
|
|
|
|
# Get tags (all if include_tags=true, else limit 5)
|
|
tags_query = db.query(Tag).filter(Tag.package_id == pkg.id).order_by(Tag.created_at.desc())
|
|
if not include_tags:
|
|
tags_query = tags_query.limit(5)
|
|
tags_objs = tags_query.all()
|
|
recent_tags = [
|
|
TagSummary(name=t.name, artifact_id=t.artifact_id, created_at=t.created_at)
|
|
for t in tags_objs
|
|
]
|
|
|
|
return PackageDetailResponse(
|
|
id=pkg.id,
|
|
project_id=pkg.project_id,
|
|
name=pkg.name,
|
|
description=pkg.description,
|
|
format=pkg.format,
|
|
platform=pkg.platform,
|
|
created_at=pkg.created_at,
|
|
updated_at=pkg.updated_at,
|
|
tag_count=tag_count,
|
|
artifact_count=artifact_count,
|
|
total_size=total_size,
|
|
latest_tag=latest_tag,
|
|
latest_upload_at=latest_upload,
|
|
recent_tags=recent_tags,
|
|
)
|
|
|
|
|
|
@router.post("/api/v1/project/{project_name}/packages", response_model=PackageResponse)
|
|
def create_package(project_name: str, package: PackageCreate, db: Session = Depends(get_db)):
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
# Validate format
|
|
if package.format not in PACKAGE_FORMATS:
|
|
raise HTTPException(status_code=400, detail=f"Invalid format. Must be one of: {', '.join(PACKAGE_FORMATS)}")
|
|
|
|
# Validate platform
|
|
if package.platform not in PACKAGE_PLATFORMS:
|
|
raise HTTPException(status_code=400, detail=f"Invalid platform. Must be one of: {', '.join(PACKAGE_PLATFORMS)}")
|
|
|
|
existing = db.query(Package).filter(Package.project_id == project.id, Package.name == package.name).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Package already exists in this project")
|
|
|
|
db_package = Package(
|
|
project_id=project.id,
|
|
name=package.name,
|
|
description=package.description,
|
|
format=package.format,
|
|
platform=package.platform,
|
|
)
|
|
db.add(db_package)
|
|
db.commit()
|
|
db.refresh(db_package)
|
|
return db_package
|
|
|
|
|
|
# Upload artifact
|
|
@router.post("/api/v1/project/{project_name}/{package_name}/upload", response_model=UploadResponse)
|
|
def upload_artifact(
|
|
project_name: str,
|
|
package_name: str,
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
tag: Optional[str] = Form(None),
|
|
db: Session = Depends(get_db),
|
|
storage: S3Storage = Depends(get_storage),
|
|
content_length: Optional[int] = Header(None, alias="Content-Length"),
|
|
):
|
|
user_id = get_user_id(request)
|
|
|
|
# Get project and package
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
# Extract format-specific metadata before storing
|
|
file_metadata = {}
|
|
if file.filename:
|
|
# Read file into memory for metadata extraction
|
|
file_content = file.file.read()
|
|
file.file.seek(0)
|
|
|
|
# Extract metadata
|
|
file_metadata = extract_metadata(
|
|
io.BytesIO(file_content),
|
|
file.filename,
|
|
file.content_type
|
|
)
|
|
|
|
# Store file (uses multipart for large files)
|
|
sha256_hash, size, s3_key = storage.store(file.file, content_length)
|
|
|
|
# Check if this is a deduplicated upload
|
|
deduplicated = False
|
|
|
|
# Create or update artifact record
|
|
artifact = db.query(Artifact).filter(Artifact.id == sha256_hash).first()
|
|
if artifact:
|
|
artifact.ref_count += 1
|
|
deduplicated = True
|
|
# Merge metadata if new metadata was extracted
|
|
if file_metadata and artifact.format_metadata:
|
|
artifact.format_metadata = {**artifact.format_metadata, **file_metadata}
|
|
elif file_metadata:
|
|
artifact.format_metadata = file_metadata
|
|
else:
|
|
artifact = Artifact(
|
|
id=sha256_hash,
|
|
size=size,
|
|
content_type=file.content_type,
|
|
original_name=file.filename,
|
|
created_by=user_id,
|
|
s3_key=s3_key,
|
|
format_metadata=file_metadata or {},
|
|
)
|
|
db.add(artifact)
|
|
|
|
# Record upload
|
|
upload = Upload(
|
|
artifact_id=sha256_hash,
|
|
package_id=package.id,
|
|
original_name=file.filename,
|
|
uploaded_by=user_id,
|
|
source_ip=request.client.host if request.client else None,
|
|
)
|
|
db.add(upload)
|
|
|
|
# Create tag if provided
|
|
if tag:
|
|
existing_tag = db.query(Tag).filter(Tag.package_id == package.id, Tag.name == tag).first()
|
|
if existing_tag:
|
|
existing_tag.artifact_id = sha256_hash
|
|
existing_tag.created_by = user_id
|
|
else:
|
|
new_tag = Tag(
|
|
package_id=package.id,
|
|
name=tag,
|
|
artifact_id=sha256_hash,
|
|
created_by=user_id,
|
|
)
|
|
db.add(new_tag)
|
|
|
|
db.commit()
|
|
|
|
return UploadResponse(
|
|
artifact_id=sha256_hash,
|
|
size=size,
|
|
project=project_name,
|
|
package=package_name,
|
|
tag=tag,
|
|
format_metadata=artifact.format_metadata,
|
|
deduplicated=deduplicated,
|
|
)
|
|
|
|
|
|
# Resumable upload endpoints
|
|
@router.post("/api/v1/project/{project_name}/{package_name}/upload/init", response_model=ResumableUploadInitResponse)
|
|
def init_resumable_upload(
|
|
project_name: str,
|
|
package_name: str,
|
|
init_request: ResumableUploadInitRequest,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
storage: S3Storage = Depends(get_storage),
|
|
):
|
|
"""
|
|
Initialize a resumable upload session.
|
|
Client must provide the SHA256 hash of the file in advance.
|
|
"""
|
|
user_id = get_user_id(request)
|
|
|
|
# Validate project and package
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
# Check if artifact already exists (deduplication)
|
|
existing_artifact = db.query(Artifact).filter(Artifact.id == init_request.expected_hash).first()
|
|
if existing_artifact:
|
|
# File already exists - increment ref count and return immediately
|
|
existing_artifact.ref_count += 1
|
|
|
|
# Record the upload
|
|
upload = Upload(
|
|
artifact_id=init_request.expected_hash,
|
|
package_id=package.id,
|
|
original_name=init_request.filename,
|
|
uploaded_by=user_id,
|
|
source_ip=request.client.host if request.client else None,
|
|
)
|
|
db.add(upload)
|
|
|
|
# Create tag if provided
|
|
if init_request.tag:
|
|
existing_tag = db.query(Tag).filter(
|
|
Tag.package_id == package.id, Tag.name == init_request.tag
|
|
).first()
|
|
if existing_tag:
|
|
existing_tag.artifact_id = init_request.expected_hash
|
|
existing_tag.created_by = user_id
|
|
else:
|
|
new_tag = Tag(
|
|
package_id=package.id,
|
|
name=init_request.tag,
|
|
artifact_id=init_request.expected_hash,
|
|
created_by=user_id,
|
|
)
|
|
db.add(new_tag)
|
|
|
|
db.commit()
|
|
|
|
return ResumableUploadInitResponse(
|
|
upload_id=None,
|
|
already_exists=True,
|
|
artifact_id=init_request.expected_hash,
|
|
chunk_size=MULTIPART_CHUNK_SIZE,
|
|
)
|
|
|
|
# Initialize resumable upload
|
|
session = storage.initiate_resumable_upload(init_request.expected_hash)
|
|
|
|
return ResumableUploadInitResponse(
|
|
upload_id=session["upload_id"],
|
|
already_exists=False,
|
|
artifact_id=None,
|
|
chunk_size=MULTIPART_CHUNK_SIZE,
|
|
)
|
|
|
|
|
|
@router.put("/api/v1/project/{project_name}/{package_name}/upload/{upload_id}/part/{part_number}")
|
|
def upload_part(
|
|
project_name: str,
|
|
package_name: str,
|
|
upload_id: str,
|
|
part_number: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
storage: S3Storage = Depends(get_storage),
|
|
):
|
|
"""
|
|
Upload a part of a resumable upload.
|
|
Part numbers start at 1.
|
|
"""
|
|
# Validate project and package exist
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
if part_number < 1:
|
|
raise HTTPException(status_code=400, detail="Part number must be >= 1")
|
|
|
|
# Read part data from request body
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
|
|
async def read_body():
|
|
return await request.body()
|
|
|
|
try:
|
|
data = loop.run_until_complete(read_body())
|
|
finally:
|
|
loop.close()
|
|
|
|
if not data:
|
|
raise HTTPException(status_code=400, detail="No data in request body")
|
|
|
|
try:
|
|
part_info = storage.upload_part(upload_id, part_number, data)
|
|
return ResumableUploadPartResponse(
|
|
part_number=part_info["PartNumber"],
|
|
etag=part_info["ETag"],
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
@router.post("/api/v1/project/{project_name}/{package_name}/upload/{upload_id}/complete")
|
|
def complete_resumable_upload(
|
|
project_name: str,
|
|
package_name: str,
|
|
upload_id: str,
|
|
complete_request: ResumableUploadCompleteRequest,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
storage: S3Storage = Depends(get_storage),
|
|
):
|
|
"""Complete a resumable upload"""
|
|
user_id = get_user_id(request)
|
|
|
|
# Validate project and package
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
try:
|
|
sha256_hash, s3_key = storage.complete_resumable_upload(upload_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
# Get file size from S3
|
|
obj_info = storage.get_object_info(s3_key)
|
|
size = obj_info["size"] if obj_info else 0
|
|
|
|
# Create artifact record
|
|
artifact = Artifact(
|
|
id=sha256_hash,
|
|
size=size,
|
|
s3_key=s3_key,
|
|
created_by=user_id,
|
|
format_metadata={},
|
|
)
|
|
db.add(artifact)
|
|
|
|
# Record upload
|
|
upload = Upload(
|
|
artifact_id=sha256_hash,
|
|
package_id=package.id,
|
|
uploaded_by=user_id,
|
|
source_ip=request.client.host if request.client else None,
|
|
)
|
|
db.add(upload)
|
|
|
|
# Create tag if provided
|
|
if complete_request.tag:
|
|
existing_tag = db.query(Tag).filter(
|
|
Tag.package_id == package.id, Tag.name == complete_request.tag
|
|
).first()
|
|
if existing_tag:
|
|
existing_tag.artifact_id = sha256_hash
|
|
existing_tag.created_by = user_id
|
|
else:
|
|
new_tag = Tag(
|
|
package_id=package.id,
|
|
name=complete_request.tag,
|
|
artifact_id=sha256_hash,
|
|
created_by=user_id,
|
|
)
|
|
db.add(new_tag)
|
|
|
|
db.commit()
|
|
|
|
return ResumableUploadCompleteResponse(
|
|
artifact_id=sha256_hash,
|
|
size=size,
|
|
project=project_name,
|
|
package=package_name,
|
|
tag=complete_request.tag,
|
|
)
|
|
|
|
|
|
@router.delete("/api/v1/project/{project_name}/{package_name}/upload/{upload_id}")
|
|
def abort_resumable_upload(
|
|
project_name: str,
|
|
package_name: str,
|
|
upload_id: str,
|
|
storage: S3Storage = Depends(get_storage),
|
|
):
|
|
"""Abort a resumable upload"""
|
|
try:
|
|
storage.abort_resumable_upload(upload_id)
|
|
return {"status": "aborted"}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
@router.get("/api/v1/project/{project_name}/{package_name}/upload/{upload_id}/status")
|
|
def get_upload_status(
|
|
project_name: str,
|
|
package_name: str,
|
|
upload_id: str,
|
|
storage: S3Storage = Depends(get_storage),
|
|
):
|
|
"""Get status of a resumable upload"""
|
|
try:
|
|
parts = storage.list_upload_parts(upload_id)
|
|
uploaded_parts = [p["PartNumber"] for p in parts]
|
|
total_bytes = sum(p.get("Size", 0) for p in parts)
|
|
|
|
return ResumableUploadStatusResponse(
|
|
upload_id=upload_id,
|
|
uploaded_parts=uploaded_parts,
|
|
total_uploaded_bytes=total_bytes,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
# Download artifact with range request support
|
|
@router.get("/api/v1/project/{project_name}/{package_name}/+/{ref}")
|
|
def download_artifact(
|
|
project_name: str,
|
|
package_name: str,
|
|
ref: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
storage: S3Storage = Depends(get_storage),
|
|
range: Optional[str] = Header(None),
|
|
):
|
|
# Get project and package
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
# Resolve reference to artifact
|
|
artifact = None
|
|
|
|
# Check for explicit prefixes
|
|
if ref.startswith("artifact:"):
|
|
artifact_id = ref[9:]
|
|
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
|
elif ref.startswith("tag:") or ref.startswith("version:"):
|
|
tag_name = ref.split(":", 1)[1]
|
|
tag = db.query(Tag).filter(Tag.package_id == package.id, Tag.name == tag_name).first()
|
|
if tag:
|
|
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
|
else:
|
|
# Try as tag name first
|
|
tag = db.query(Tag).filter(Tag.package_id == package.id, Tag.name == ref).first()
|
|
if tag:
|
|
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
|
else:
|
|
# Try as direct artifact ID
|
|
artifact = db.query(Artifact).filter(Artifact.id == ref).first()
|
|
|
|
if not artifact:
|
|
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
|
|
filename = artifact.original_name or f"{artifact.id}"
|
|
|
|
# Handle range requests
|
|
if range:
|
|
stream, content_length, content_range = storage.get_stream(artifact.s3_key, range)
|
|
|
|
headers = {
|
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(content_length),
|
|
}
|
|
if content_range:
|
|
headers["Content-Range"] = content_range
|
|
|
|
return StreamingResponse(
|
|
stream,
|
|
status_code=206, # Partial Content
|
|
media_type=artifact.content_type or "application/octet-stream",
|
|
headers=headers,
|
|
)
|
|
|
|
# Full download
|
|
stream, content_length, _ = storage.get_stream(artifact.s3_key)
|
|
|
|
return StreamingResponse(
|
|
stream,
|
|
media_type=artifact.content_type or "application/octet-stream",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(content_length),
|
|
},
|
|
)
|
|
|
|
|
|
# HEAD request for download (to check file info without downloading)
|
|
@router.head("/api/v1/project/{project_name}/{package_name}/+/{ref}")
|
|
def head_artifact(
|
|
project_name: str,
|
|
package_name: str,
|
|
ref: str,
|
|
db: Session = Depends(get_db),
|
|
storage: S3Storage = Depends(get_storage),
|
|
):
|
|
# Get project and package
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
# Resolve reference to artifact (same logic as download)
|
|
artifact = None
|
|
if ref.startswith("artifact:"):
|
|
artifact_id = ref[9:]
|
|
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
|
elif ref.startswith("tag:") or ref.startswith("version:"):
|
|
tag_name = ref.split(":", 1)[1]
|
|
tag = db.query(Tag).filter(Tag.package_id == package.id, Tag.name == tag_name).first()
|
|
if tag:
|
|
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
|
else:
|
|
tag = db.query(Tag).filter(Tag.package_id == package.id, Tag.name == ref).first()
|
|
if tag:
|
|
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
|
else:
|
|
artifact = db.query(Artifact).filter(Artifact.id == ref).first()
|
|
|
|
if not artifact:
|
|
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
|
|
filename = artifact.original_name or f"{artifact.id}"
|
|
|
|
return Response(
|
|
content=b"",
|
|
media_type=artifact.content_type or "application/octet-stream",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(artifact.size),
|
|
"X-Artifact-Id": artifact.id,
|
|
},
|
|
)
|
|
|
|
|
|
# Compatibility route
|
|
@router.get("/project/{project_name}/{package_name}/+/{ref}")
|
|
def download_artifact_compat(
|
|
project_name: str,
|
|
package_name: str,
|
|
ref: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
storage: S3Storage = Depends(get_storage),
|
|
range: Optional[str] = Header(None),
|
|
):
|
|
return download_artifact(project_name, package_name, ref, request, db, storage, range)
|
|
|
|
|
|
# Tag routes
|
|
@router.get("/api/v1/project/{project_name}/{package_name}/tags", response_model=List[TagResponse])
|
|
def list_tags(project_name: str, package_name: str, db: Session = Depends(get_db)):
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
tags = db.query(Tag).filter(Tag.package_id == package.id).order_by(Tag.name).all()
|
|
return tags
|
|
|
|
|
|
@router.post("/api/v1/project/{project_name}/{package_name}/tags", response_model=TagResponse)
|
|
def create_tag(
|
|
project_name: str,
|
|
package_name: str,
|
|
tag: TagCreate,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
user_id = get_user_id(request)
|
|
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
# Verify artifact exists
|
|
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
|
|
if not artifact:
|
|
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
|
|
# Create or update tag
|
|
existing = db.query(Tag).filter(Tag.package_id == package.id, Tag.name == tag.name).first()
|
|
if existing:
|
|
existing.artifact_id = tag.artifact_id
|
|
existing.created_by = user_id
|
|
db.commit()
|
|
db.refresh(existing)
|
|
return existing
|
|
|
|
db_tag = Tag(
|
|
package_id=package.id,
|
|
name=tag.name,
|
|
artifact_id=tag.artifact_id,
|
|
created_by=user_id,
|
|
)
|
|
db.add(db_tag)
|
|
db.commit()
|
|
db.refresh(db_tag)
|
|
return db_tag
|
|
|
|
|
|
# Consumer routes
|
|
@router.get("/api/v1/project/{project_name}/{package_name}/consumers", response_model=List[ConsumerResponse])
|
|
def get_consumers(project_name: str, package_name: str, db: Session = Depends(get_db)):
|
|
project = db.query(Project).filter(Project.name == project_name).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
consumers = db.query(Consumer).filter(Consumer.package_id == package.id).order_by(Consumer.last_access.desc()).all()
|
|
return consumers
|
|
|
|
|
|
# Artifact by ID
|
|
@router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactResponse)
|
|
def get_artifact(artifact_id: str, db: Session = Depends(get_db)):
|
|
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
|
if not artifact:
|
|
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
return artifact
|