- Grove → Project - Tree → Package - Fruit → Artifact - Graft → Tag - Cultivate → Upload - Harvest → Download Updated across: - Backend models, schemas, and routes - Frontend types, API client, and components - README documentation - API endpoints now use /project/:project/packages pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
334 lines
11 KiB
Python
334 lines
11 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import or_
|
|
from typing import List, Optional
|
|
import re
|
|
|
|
from .database import get_db
|
|
from .storage import get_storage, S3Storage
|
|
from .models import Project, Package, Artifact, Tag, Upload, Consumer
|
|
from .schemas import (
|
|
ProjectCreate, ProjectResponse,
|
|
PackageCreate, PackageResponse,
|
|
ArtifactResponse,
|
|
TagCreate, TagResponse,
|
|
UploadResponse,
|
|
ConsumerResponse,
|
|
HealthResponse,
|
|
)
|
|
|
|
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=List[ProjectResponse])
|
|
def list_projects(request: Request, db: Session = Depends(get_db)):
|
|
user_id = get_user_id(request)
|
|
projects = db.query(Project).filter(
|
|
or_(Project.is_public == True, Project.created_by == user_id)
|
|
).order_by(Project.name).all()
|
|
return projects
|
|
|
|
|
|
@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=List[PackageResponse])
|
|
def list_packages(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")
|
|
|
|
packages = db.query(Package).filter(Package.project_id == project.id).order_by(Package.name).all()
|
|
return packages
|
|
|
|
|
|
@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")
|
|
|
|
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,
|
|
)
|
|
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),
|
|
):
|
|
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")
|
|
|
|
# Store file
|
|
sha256_hash, size, s3_key = storage.store(file.file)
|
|
|
|
# Create or update artifact record
|
|
artifact = db.query(Artifact).filter(Artifact.id == sha256_hash).first()
|
|
if artifact:
|
|
artifact.ref_count += 1
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
|
|
|
|
# Download artifact
|
|
@router.get("/api/v1/project/{project_name}/{package_name}/+/{ref}")
|
|
def download_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
|
|
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")
|
|
|
|
# Stream from S3
|
|
stream = storage.get_stream(artifact.s3_key)
|
|
|
|
filename = artifact.original_name or f"{artifact.id}"
|
|
|
|
return StreamingResponse(
|
|
stream,
|
|
media_type=artifact.content_type or "application/octet-stream",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
# Compatibility route
|
|
@router.get("/project/{project_name}/{package_name}/+/{ref}")
|
|
def download_artifact_compat(
|
|
project_name: str,
|
|
package_name: str,
|
|
ref: str,
|
|
db: Session = Depends(get_db),
|
|
storage: S3Storage = Depends(get_storage),
|
|
):
|
|
return download_artifact(project_name, package_name, ref, db, storage)
|
|
|
|
|
|
# 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
|