diff --git a/README.md b/README.md index 8abe1db..ba48e11 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,13 @@ Orchard is a centralized binary artifact storage system that provides content-ad | `POST` | `/api/v1/project/:project/:package/upload` | Upload an artifact | | `GET` | `/api/v1/project/:project/:package/+/:ref` | Download an artifact (supports Range header) | | `HEAD` | `/api/v1/project/:project/:package/+/:ref` | Get artifact metadata without downloading | -| `GET` | `/api/v1/project/:project/:package/tags` | List all tags | +| `GET` | `/api/v1/project/:project/:package/tags` | List tags (with pagination, search, sorting, artifact metadata) | | `POST` | `/api/v1/project/:project/:package/tags` | Create a tag | +| `GET` | `/api/v1/project/:project/:package/tags/:tag_name` | Get single tag with artifact metadata | +| `GET` | `/api/v1/project/:project/:package/tags/:tag_name/history` | Get tag change history | +| `GET` | `/api/v1/project/:project/:package/artifacts` | List artifacts in package (with filtering) | | `GET` | `/api/v1/project/:project/:package/consumers` | List consumers of a package | -| `GET` | `/api/v1/artifact/:id` | Get artifact metadata by hash | +| `GET` | `/api/v1/artifact/:id` | Get artifact metadata with referencing tags | #### Resumable Upload Endpoints @@ -290,12 +293,119 @@ curl -X POST http://localhost:8080/api/v1/project/my-project/releases/tags \ -d '{"name": "stable", "artifact_id": "a3f5d8e12b4c6789..."}' ``` +### List Tags + +```bash +# Basic listing with artifact metadata +curl http://localhost:8080/api/v1/project/my-project/releases/tags + +# With pagination +curl "http://localhost:8080/api/v1/project/my-project/releases/tags?page=1&limit=10" + +# Search by tag name +curl "http://localhost:8080/api/v1/project/my-project/releases/tags?search=v1" + +# Sort by created_at descending +curl "http://localhost:8080/api/v1/project/my-project/releases/tags?sort=created_at&order=desc" +``` + +Response includes artifact metadata: +```json +{ + "items": [ + { + "id": "uuid", + "package_id": "uuid", + "name": "v1.0.0", + "artifact_id": "a3f5d8e...", + "created_at": "2025-01-01T00:00:00Z", + "created_by": "user", + "artifact_size": 1048576, + "artifact_content_type": "application/gzip", + "artifact_original_name": "app-v1.0.0.tar.gz", + "artifact_created_at": "2025-01-01T00:00:00Z", + "artifact_format_metadata": {} + } + ], + "pagination": {"page": 1, "limit": 20, "total": 1, "total_pages": 1} +} +``` + +### Get Single Tag + +```bash +curl http://localhost:8080/api/v1/project/my-project/releases/tags/v1.0.0 +``` + +### Get Tag History + +```bash +curl http://localhost:8080/api/v1/project/my-project/releases/tags/latest/history +``` + +Returns list of artifact changes for the tag (most recent first). + +### List Artifacts in Package + +```bash +# Basic listing +curl http://localhost:8080/api/v1/project/my-project/releases/artifacts + +# Filter by content type +curl "http://localhost:8080/api/v1/project/my-project/releases/artifacts?content_type=application/gzip" + +# Filter by date range +curl "http://localhost:8080/api/v1/project/my-project/releases/artifacts?created_after=2025-01-01T00:00:00Z" +``` + +Response includes tags pointing to each artifact: +```json +{ + "items": [ + { + "id": "a3f5d8e...", + "size": 1048576, + "content_type": "application/gzip", + "original_name": "app-v1.0.0.tar.gz", + "created_at": "2025-01-01T00:00:00Z", + "created_by": "user", + "format_metadata": {}, + "tags": ["v1.0.0", "latest", "stable"] + } + ], + "pagination": {"page": 1, "limit": 20, "total": 1, "total_pages": 1} +} +``` + ### Get Artifact by ID ```bash curl http://localhost:8080/api/v1/artifact/a3f5d8e12b4c67890abcdef1234567890abcdef1234567890abcdef12345678 ``` +Response includes all tags/packages referencing the artifact: +```json +{ + "id": "a3f5d8e...", + "size": 1048576, + "content_type": "application/gzip", + "original_name": "app-v1.0.0.tar.gz", + "created_at": "2025-01-01T00:00:00Z", + "created_by": "user", + "ref_count": 2, + "format_metadata": {}, + "tags": [ + { + "id": "uuid", + "name": "v1.0.0", + "package_id": "uuid", + "package_name": "releases", + "project_name": "my-project" + } + ] +} +``` + ## Project Structure ``` 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