From 54e33e67ce339008dc22d07345f5827fd8086a0f Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Fri, 12 Dec 2025 09:38:06 -0600 Subject: [PATCH] Add API endpoints for listing tagged versions and artifacts - Enhanced GET /api/v1/project/{project}/{package}/tags with pagination, search, sort, order parameters and artifact metadata - Added GET /api/v1/project/{project}/{package}/tags/{tag_name} for single tag with full artifact details - Added GET /api/v1/project/{project}/{package}/tags/{tag_name}/history for tag change history - Added GET /api/v1/project/{project}/{package}/artifacts for listing all artifacts in a package with filtering by content_type and date range - Enhanced GET /api/v1/artifact/{artifact_id} to include list of packages/tags referencing the artifact - Added new schemas: TagDetailResponse, TagHistoryResponse, ArtifactDetailResponse, ArtifactTagInfo, PackageArtifactResponse --- backend/app/routes.py | 261 +++++++++++++++++++++++++++++++++++++++-- backend/app/schemas.py | 72 ++++++++++++ 2 files changed, 324 insertions(+), 9 deletions(-) diff --git a/backend/app/routes.py b/backend/app/routes.py index ab012a8..711225c 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -1,3 +1,4 @@ +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Header, Response from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -10,13 +11,13 @@ 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 .models import Project, Package, Artifact, Tag, TagHistory, Upload, Consumer from .schemas import ( ProjectCreate, ProjectResponse, PackageCreate, PackageResponse, PackageDetailResponse, TagSummary, PACKAGE_FORMATS, PACKAGE_PLATFORMS, - ArtifactResponse, - TagCreate, TagResponse, + ArtifactResponse, ArtifactDetailResponse, ArtifactTagInfo, PackageArtifactResponse, + TagCreate, TagResponse, TagDetailResponse, TagHistoryResponse, UploadResponse, ConsumerResponse, HealthResponse, @@ -850,8 +851,17 @@ def download_artifact_compat( # 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)): +@router.get("/api/v1/project/{project_name}/{package_name}/tags", response_model=PaginatedResponse[TagDetailResponse]) +def list_tags( + project_name: str, + package_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 tag name"), + sort: str = Query(default="name", description="Sort field (name, created_at)"), + order: str = Query(default="asc", description="Sort order (asc, desc)"), + 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") @@ -860,8 +870,65 @@ def list_tags(project_name: str, package_name: str, db: Session = Depends(get_db 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 + # Validate sort field + valid_sort_fields = {"name": Tag.name, "created_at": Tag.created_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'") + + # Base query with JOIN to artifact for metadata + query = db.query(Tag, Artifact).join(Artifact, Tag.artifact_id == Artifact.id).filter(Tag.package_id == package.id) + + # Apply search filter (case-insensitive on tag name) + if search: + query = query.filter(func.lower(Tag.name).contains(search.lower())) + + # 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 + results = query.offset(offset).limit(limit).all() + + # Calculate total pages + total_pages = math.ceil(total / limit) if total > 0 else 1 + + # Build detailed responses with artifact metadata + detailed_tags = [] + for tag, artifact in results: + detailed_tags.append(TagDetailResponse( + id=tag.id, + package_id=tag.package_id, + name=tag.name, + artifact_id=tag.artifact_id, + created_at=tag.created_at, + created_by=tag.created_by, + artifact_size=artifact.size, + artifact_content_type=artifact.content_type, + artifact_original_name=artifact.original_name, + artifact_created_at=artifact.created_at, + artifact_format_metadata=artifact.format_metadata, + )) + + return PaginatedResponse( + items=detailed_tags, + pagination=PaginationMeta( + page=page, + limit=limit, + total=total, + total_pages=total_pages, + ), + ) @router.post("/api/v1/project/{project_name}/{package_name}/tags", response_model=TagResponse) @@ -908,6 +975,70 @@ def create_tag( return db_tag +@router.get("/api/v1/project/{project_name}/{package_name}/tags/{tag_name}", response_model=TagDetailResponse) +def get_tag( + project_name: str, + package_name: str, + tag_name: str, + db: Session = Depends(get_db), +): + """Get a single tag with full artifact metadata""" + 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") + + result = db.query(Tag, Artifact).join(Artifact, Tag.artifact_id == Artifact.id).filter( + Tag.package_id == package.id, + Tag.name == tag_name + ).first() + + if not result: + raise HTTPException(status_code=404, detail="Tag not found") + + tag, artifact = result + return TagDetailResponse( + id=tag.id, + package_id=tag.package_id, + name=tag.name, + artifact_id=tag.artifact_id, + created_at=tag.created_at, + created_by=tag.created_by, + artifact_size=artifact.size, + artifact_content_type=artifact.content_type, + artifact_original_name=artifact.original_name, + artifact_created_at=artifact.created_at, + artifact_format_metadata=artifact.format_metadata, + ) + + +@router.get("/api/v1/project/{project_name}/{package_name}/tags/{tag_name}/history", response_model=List[TagHistoryResponse]) +def get_tag_history( + project_name: str, + package_name: str, + tag_name: str, + db: Session = Depends(get_db), +): + """Get the history of artifact assignments for a tag""" + 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") + + tag = db.query(Tag).filter(Tag.package_id == package.id, Tag.name == tag_name).first() + if not tag: + raise HTTPException(status_code=404, detail="Tag not found") + + history = db.query(TagHistory).filter(TagHistory.tag_id == tag.id).order_by(TagHistory.changed_at.desc()).all() + return history + + # 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)): @@ -923,10 +1054,122 @@ def get_consumers(project_name: str, package_name: str, db: Session = Depends(ge return consumers +# Package artifacts +@router.get("/api/v1/project/{project_name}/{package_name}/artifacts", response_model=PaginatedResponse[PackageArtifactResponse]) +def list_package_artifacts( + project_name: str, + package_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"), + content_type: Optional[str] = Query(default=None, description="Filter by content type"), + created_after: Optional[datetime] = Query(default=None, description="Filter artifacts created after this date"), + created_before: Optional[datetime] = Query(default=None, description="Filter artifacts created before this date"), + db: Session = Depends(get_db), +): + """List all unique artifacts uploaded to a 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") + + # Get distinct artifacts uploaded to this package via uploads table + artifact_ids_subquery = db.query(func.distinct(Upload.artifact_id)).filter( + Upload.package_id == package.id + ).subquery() + + query = db.query(Artifact).filter(Artifact.id.in_(artifact_ids_subquery)) + + # Apply content_type filter + if content_type: + query = query.filter(Artifact.content_type == content_type) + + # Apply date range filters + if created_after: + query = query.filter(Artifact.created_at >= created_after) + if created_before: + query = query.filter(Artifact.created_at <= created_before) + + # Get total count before pagination + total = query.count() + + # Apply pagination + offset = (page - 1) * limit + artifacts = query.order_by(Artifact.created_at.desc()).offset(offset).limit(limit).all() + + # Calculate total pages + total_pages = math.ceil(total / limit) if total > 0 else 1 + + # Build responses with tag info + artifact_responses = [] + for artifact in artifacts: + # Get tags pointing to this artifact in this package + tags = db.query(Tag.name).filter( + Tag.package_id == package.id, + Tag.artifact_id == artifact.id + ).all() + tag_names = [t.name for t in tags] + + artifact_responses.append(PackageArtifactResponse( + id=artifact.id, + size=artifact.size, + content_type=artifact.content_type, + original_name=artifact.original_name, + created_at=artifact.created_at, + created_by=artifact.created_by, + format_metadata=artifact.format_metadata, + tags=tag_names, + )) + + return PaginatedResponse( + items=artifact_responses, + pagination=PaginationMeta( + page=page, + limit=limit, + total=total, + total_pages=total_pages, + ), + ) + + # Artifact by ID -@router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactResponse) +@router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactDetailResponse) def get_artifact(artifact_id: str, db: Session = Depends(get_db)): + """Get artifact metadata including list of packages/tags referencing it""" artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() if not artifact: raise HTTPException(status_code=404, detail="Artifact not found") - return artifact + + # Get all tags referencing this artifact with package and project info + tags_with_context = db.query(Tag, Package, Project).join( + Package, Tag.package_id == Package.id + ).join( + Project, Package.project_id == Project.id + ).filter( + Tag.artifact_id == artifact_id + ).all() + + tag_infos = [ + ArtifactTagInfo( + id=tag.id, + name=tag.name, + package_id=package.id, + package_name=package.name, + project_name=project.name, + ) + for tag, package, project in tags_with_context + ] + + return ArtifactDetailResponse( + id=artifact.id, + size=artifact.size, + content_type=artifact.content_type, + original_name=artifact.original_name, + created_at=artifact.created_at, + created_by=artifact.created_by, + ref_count=artifact.ref_count, + format_metadata=artifact.format_metadata, + tags=tag_infos, + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 837c0ce..5077646 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -129,6 +129,78 @@ class TagResponse(BaseModel): from_attributes = True +class TagDetailResponse(BaseModel): + """Tag with embedded artifact metadata""" + id: UUID + package_id: UUID + name: str + artifact_id: str + created_at: datetime + created_by: str + # Artifact metadata + artifact_size: int + artifact_content_type: Optional[str] + artifact_original_name: Optional[str] + artifact_created_at: datetime + artifact_format_metadata: Optional[Dict[str, Any]] = None + + class Config: + from_attributes = True + + +class TagHistoryResponse(BaseModel): + """History entry for tag changes""" + id: UUID + tag_id: UUID + old_artifact_id: Optional[str] + new_artifact_id: str + changed_at: datetime + changed_by: str + + class Config: + from_attributes = True + + +class ArtifactTagInfo(BaseModel): + """Tag info for embedding in artifact responses""" + id: UUID + name: str + package_id: UUID + package_name: str + project_name: str + + +class ArtifactDetailResponse(BaseModel): + """Artifact with list of tags/packages referencing it""" + id: str + size: int + content_type: Optional[str] + original_name: Optional[str] + created_at: datetime + created_by: str + ref_count: int + format_metadata: Optional[Dict[str, Any]] = None + tags: List[ArtifactTagInfo] = [] + + class Config: + from_attributes = True + + +class PackageArtifactResponse(BaseModel): + """Artifact with tags for package artifact listing""" + id: str + size: int + content_type: Optional[str] + original_name: Optional[str] + created_at: datetime + created_by: str + format_metadata: Optional[Dict[str, Any]] = None + tags: List[str] = [] # Tag names pointing to this artifact + + class Config: + from_attributes = True + + # Upload response class UploadResponse(BaseModel): artifact_id: str