Rewrite from Go + vanilla JS to Python (FastAPI) + React (TypeScript)
- Backend: Python 3.12 with FastAPI, SQLAlchemy, boto3 - Frontend: React 18 with TypeScript, Vite build tooling - Updated Dockerfile for multi-stage Node + Python build - Updated CI pipeline for Python backend - Removed old Go code (cmd/, internal/, go.mod, go.sum) - Updated README with new tech stack documentation
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
38
backend/app/config.py
Normal file
38
backend/app/config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Server
|
||||
server_host: str = "0.0.0.0"
|
||||
server_port: int = 8080
|
||||
|
||||
# Database
|
||||
database_host: str = "localhost"
|
||||
database_port: int = 5432
|
||||
database_user: str = "orchard"
|
||||
database_password: str = ""
|
||||
database_dbname: str = "orchard"
|
||||
database_sslmode: str = "disable"
|
||||
|
||||
# S3
|
||||
s3_endpoint: str = ""
|
||||
s3_region: str = "us-east-1"
|
||||
s3_bucket: str = "orchard-artifacts"
|
||||
s3_access_key_id: str = ""
|
||||
s3_secret_access_key: str = ""
|
||||
s3_use_path_style: bool = True
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""
|
||||
return f"postgresql://{self.database_user}:{self.database_password}@{self.database_host}:{self.database_port}/{self.database_dbname}{sslmode}"
|
||||
|
||||
class Config:
|
||||
env_prefix = "ORCHARD_"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
25
backend/app/database.py
Normal file
25
backend/app/database.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
|
||||
from .config import get_settings
|
||||
from .models import Base
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Create all tables"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""Dependency for getting database sessions"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
54
backend/app/main.py
Normal file
54
backend/app/main.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
|
||||
from .config import get_settings
|
||||
from .database import init_db
|
||||
from .routes import router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: initialize database
|
||||
init_db()
|
||||
yield
|
||||
# Shutdown: cleanup if needed
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Orchard",
|
||||
description="Content-Addressable Storage System",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(router)
|
||||
|
||||
# Serve static files (React build) if the directory exists
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
|
||||
|
||||
@app.get("/")
|
||||
async def serve_spa():
|
||||
return FileResponse(os.path.join(static_dir, "index.html"))
|
||||
|
||||
# Catch-all for SPA routing (must be last)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa_routes(full_path: str):
|
||||
# Don't catch API routes
|
||||
if full_path.startswith("api/") or full_path.startswith("health") or full_path.startswith("grove/"):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
index_path = os.path.join(static_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
204
backend/app/models.py
Normal file
204
backend/app/models.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import (
|
||||
Column, String, Text, Boolean, Integer, BigInteger,
|
||||
DateTime, ForeignKey, CheckConstraint, Index, JSON
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
import uuid
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Grove(Base):
|
||||
__tablename__ = "groves"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
is_public = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_by = Column(String(255), nullable=False)
|
||||
|
||||
trees = relationship("Tree", back_populates="grove", cascade="all, delete-orphan")
|
||||
permissions = relationship("AccessPermission", back_populates="grove", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_groves_name", "name"),
|
||||
Index("idx_groves_created_by", "created_by"),
|
||||
)
|
||||
|
||||
|
||||
class Tree(Base):
|
||||
__tablename__ = "trees"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
grove_id = Column(UUID(as_uuid=True), ForeignKey("groves.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
grove = relationship("Grove", back_populates="trees")
|
||||
grafts = relationship("Graft", back_populates="tree", cascade="all, delete-orphan")
|
||||
harvests = relationship("Harvest", back_populates="tree", cascade="all, delete-orphan")
|
||||
consumers = relationship("Consumer", back_populates="tree", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_trees_grove_id", "grove_id"),
|
||||
Index("idx_trees_name", "name"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
|
||||
class Fruit(Base):
|
||||
__tablename__ = "fruits"
|
||||
|
||||
id = Column(String(64), primary_key=True) # SHA256 hash
|
||||
size = Column(BigInteger, nullable=False)
|
||||
content_type = Column(String(255))
|
||||
original_name = Column(String(1024))
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
created_by = Column(String(255), nullable=False)
|
||||
ref_count = Column(Integer, default=1)
|
||||
s3_key = Column(String(1024), nullable=False)
|
||||
|
||||
grafts = relationship("Graft", back_populates="fruit")
|
||||
harvests = relationship("Harvest", back_populates="fruit")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_fruits_created_at", "created_at"),
|
||||
Index("idx_fruits_created_by", "created_by"),
|
||||
)
|
||||
|
||||
|
||||
class Graft(Base):
|
||||
__tablename__ = "grafts"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
created_by = Column(String(255), nullable=False)
|
||||
|
||||
tree = relationship("Tree", back_populates="grafts")
|
||||
fruit = relationship("Fruit", back_populates="grafts")
|
||||
history = relationship("GraftHistory", back_populates="graft", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_grafts_tree_id", "tree_id"),
|
||||
Index("idx_grafts_fruit_id", "fruit_id"),
|
||||
)
|
||||
|
||||
|
||||
class GraftHistory(Base):
|
||||
__tablename__ = "graft_history"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
graft_id = Column(UUID(as_uuid=True), ForeignKey("grafts.id", ondelete="CASCADE"), nullable=False)
|
||||
old_fruit_id = Column(String(64), ForeignKey("fruits.id"))
|
||||
new_fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False)
|
||||
changed_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
changed_by = Column(String(255), nullable=False)
|
||||
|
||||
graft = relationship("Graft", back_populates="history")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_graft_history_graft_id", "graft_id"),
|
||||
)
|
||||
|
||||
|
||||
class Harvest(Base):
|
||||
__tablename__ = "harvests"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id"), nullable=False)
|
||||
original_name = Column(String(1024))
|
||||
harvested_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
harvested_by = Column(String(255), nullable=False)
|
||||
source_ip = Column(String(45))
|
||||
|
||||
fruit = relationship("Fruit", back_populates="harvests")
|
||||
tree = relationship("Tree", back_populates="harvests")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_harvests_fruit_id", "fruit_id"),
|
||||
Index("idx_harvests_tree_id", "tree_id"),
|
||||
Index("idx_harvests_harvested_at", "harvested_at"),
|
||||
)
|
||||
|
||||
|
||||
class Consumer(Base):
|
||||
__tablename__ = "consumers"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False)
|
||||
project_url = Column(String(2048), nullable=False)
|
||||
last_access = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
|
||||
tree = relationship("Tree", back_populates="consumers")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_consumers_tree_id", "tree_id"),
|
||||
Index("idx_consumers_last_access", "last_access"),
|
||||
)
|
||||
|
||||
|
||||
class AccessPermission(Base):
|
||||
__tablename__ = "access_permissions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
grove_id = Column(UUID(as_uuid=True), ForeignKey("groves.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
level = Column(String(20), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
expires_at = Column(DateTime(timezone=True))
|
||||
|
||||
grove = relationship("Grove", back_populates="permissions")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("level IN ('read', 'write', 'admin')", name="check_level"),
|
||||
Index("idx_access_permissions_grove_id", "grove_id"),
|
||||
Index("idx_access_permissions_user_id", "user_id"),
|
||||
)
|
||||
|
||||
|
||||
class APIKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
key_hash = Column(String(64), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
expires_at = Column(DateTime(timezone=True))
|
||||
last_used = Column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_api_keys_user_id", "user_id"),
|
||||
Index("idx_api_keys_key_hash", "key_hash"),
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
action = Column(String(100), nullable=False)
|
||||
resource = Column(String(1024), nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
details = Column(JSON)
|
||||
timestamp = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
source_ip = Column(String(45))
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_audit_logs_action", "action"),
|
||||
Index("idx_audit_logs_resource", "resource"),
|
||||
Index("idx_audit_logs_user_id", "user_id"),
|
||||
Index("idx_audit_logs_timestamp", "timestamp"),
|
||||
)
|
||||
333
backend/app/routes.py
Normal file
333
backend/app/routes.py
Normal file
@@ -0,0 +1,333 @@
|
||||
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 Grove, Tree, Fruit, Graft, Harvest, Consumer
|
||||
from .schemas import (
|
||||
GroveCreate, GroveResponse,
|
||||
TreeCreate, TreeResponse,
|
||||
FruitResponse,
|
||||
GraftCreate, GraftResponse,
|
||||
CultivateResponse,
|
||||
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")
|
||||
|
||||
|
||||
# Grove routes
|
||||
@router.get("/api/v1/groves", response_model=List[GroveResponse])
|
||||
def list_groves(request: Request, db: Session = Depends(get_db)):
|
||||
user_id = get_user_id(request)
|
||||
groves = db.query(Grove).filter(
|
||||
or_(Grove.is_public == True, Grove.created_by == user_id)
|
||||
).order_by(Grove.name).all()
|
||||
return groves
|
||||
|
||||
|
||||
@router.post("/api/v1/groves", response_model=GroveResponse)
|
||||
def create_grove(grove: GroveCreate, request: Request, db: Session = Depends(get_db)):
|
||||
user_id = get_user_id(request)
|
||||
|
||||
existing = db.query(Grove).filter(Grove.name == grove.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Grove already exists")
|
||||
|
||||
db_grove = Grove(
|
||||
name=grove.name,
|
||||
description=grove.description,
|
||||
is_public=grove.is_public,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(db_grove)
|
||||
db.commit()
|
||||
db.refresh(db_grove)
|
||||
return db_grove
|
||||
|
||||
|
||||
@router.get("/api/v1/groves/{grove_name}", response_model=GroveResponse)
|
||||
def get_grove(grove_name: str, db: Session = Depends(get_db)):
|
||||
grove = db.query(Grove).filter(Grove.name == grove_name).first()
|
||||
if not grove:
|
||||
raise HTTPException(status_code=404, detail="Grove not found")
|
||||
return grove
|
||||
|
||||
|
||||
# Tree routes
|
||||
@router.get("/api/v1/grove/{grove_name}/trees", response_model=List[TreeResponse])
|
||||
def list_trees(grove_name: str, db: Session = Depends(get_db)):
|
||||
grove = db.query(Grove).filter(Grove.name == grove_name).first()
|
||||
if not grove:
|
||||
raise HTTPException(status_code=404, detail="Grove not found")
|
||||
|
||||
trees = db.query(Tree).filter(Tree.grove_id == grove.id).order_by(Tree.name).all()
|
||||
return trees
|
||||
|
||||
|
||||
@router.post("/api/v1/grove/{grove_name}/trees", response_model=TreeResponse)
|
||||
def create_tree(grove_name: str, tree: TreeCreate, db: Session = Depends(get_db)):
|
||||
grove = db.query(Grove).filter(Grove.name == grove_name).first()
|
||||
if not grove:
|
||||
raise HTTPException(status_code=404, detail="Grove not found")
|
||||
|
||||
existing = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Tree already exists in this grove")
|
||||
|
||||
db_tree = Tree(
|
||||
grove_id=grove.id,
|
||||
name=tree.name,
|
||||
description=tree.description,
|
||||
)
|
||||
db.add(db_tree)
|
||||
db.commit()
|
||||
db.refresh(db_tree)
|
||||
return db_tree
|
||||
|
||||
|
||||
# Cultivate (upload)
|
||||
@router.post("/api/v1/grove/{grove_name}/{tree_name}/cultivate", response_model=CultivateResponse)
|
||||
def cultivate(
|
||||
grove_name: str,
|
||||
tree_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 grove and tree
|
||||
grove = db.query(Grove).filter(Grove.name == grove_name).first()
|
||||
if not grove:
|
||||
raise HTTPException(status_code=404, detail="Grove not found")
|
||||
|
||||
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=404, detail="Tree not found")
|
||||
|
||||
# Store file
|
||||
sha256_hash, size, s3_key = storage.store(file.file)
|
||||
|
||||
# Create or update fruit record
|
||||
fruit = db.query(Fruit).filter(Fruit.id == sha256_hash).first()
|
||||
if fruit:
|
||||
fruit.ref_count += 1
|
||||
else:
|
||||
fruit = Fruit(
|
||||
id=sha256_hash,
|
||||
size=size,
|
||||
content_type=file.content_type,
|
||||
original_name=file.filename,
|
||||
created_by=user_id,
|
||||
s3_key=s3_key,
|
||||
)
|
||||
db.add(fruit)
|
||||
|
||||
# Record harvest
|
||||
harvest = Harvest(
|
||||
fruit_id=sha256_hash,
|
||||
tree_id=tree.id,
|
||||
original_name=file.filename,
|
||||
harvested_by=user_id,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
)
|
||||
db.add(harvest)
|
||||
|
||||
# Create tag if provided
|
||||
if tag:
|
||||
existing_graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == tag).first()
|
||||
if existing_graft:
|
||||
existing_graft.fruit_id = sha256_hash
|
||||
existing_graft.created_by = user_id
|
||||
else:
|
||||
graft = Graft(
|
||||
tree_id=tree.id,
|
||||
name=tag,
|
||||
fruit_id=sha256_hash,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(graft)
|
||||
|
||||
db.commit()
|
||||
|
||||
return CultivateResponse(
|
||||
fruit_id=sha256_hash,
|
||||
size=size,
|
||||
grove=grove_name,
|
||||
tree=tree_name,
|
||||
tag=tag,
|
||||
)
|
||||
|
||||
|
||||
# Harvest (download)
|
||||
@router.get("/api/v1/grove/{grove_name}/{tree_name}/+/{ref}")
|
||||
def harvest(
|
||||
grove_name: str,
|
||||
tree_name: str,
|
||||
ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
storage: S3Storage = Depends(get_storage),
|
||||
):
|
||||
# Get grove and tree
|
||||
grove = db.query(Grove).filter(Grove.name == grove_name).first()
|
||||
if not grove:
|
||||
raise HTTPException(status_code=404, detail="Grove not found")
|
||||
|
||||
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=404, detail="Tree not found")
|
||||
|
||||
# Resolve reference to fruit
|
||||
fruit = None
|
||||
|
||||
# Check for explicit prefixes
|
||||
if ref.startswith("fruit:"):
|
||||
fruit_id = ref[6:]
|
||||
fruit = db.query(Fruit).filter(Fruit.id == fruit_id).first()
|
||||
elif ref.startswith("tag:") or ref.startswith("version:"):
|
||||
tag_name = ref.split(":", 1)[1]
|
||||
graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == tag_name).first()
|
||||
if graft:
|
||||
fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first()
|
||||
else:
|
||||
# Try as tag name first
|
||||
graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == ref).first()
|
||||
if graft:
|
||||
fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first()
|
||||
else:
|
||||
# Try as direct fruit ID
|
||||
fruit = db.query(Fruit).filter(Fruit.id == ref).first()
|
||||
|
||||
if not fruit:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
# Stream from S3
|
||||
stream = storage.get_stream(fruit.s3_key)
|
||||
|
||||
filename = fruit.original_name or f"{fruit.id}"
|
||||
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=fruit.content_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# Compatibility route
|
||||
@router.get("/grove/{grove_name}/{tree_name}/+/{ref}")
|
||||
def harvest_compat(
|
||||
grove_name: str,
|
||||
tree_name: str,
|
||||
ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
storage: S3Storage = Depends(get_storage),
|
||||
):
|
||||
return harvest(grove_name, tree_name, ref, db, storage)
|
||||
|
||||
|
||||
# Graft routes
|
||||
@router.get("/api/v1/grove/{grove_name}/{tree_name}/grafts", response_model=List[GraftResponse])
|
||||
def list_grafts(grove_name: str, tree_name: str, db: Session = Depends(get_db)):
|
||||
grove = db.query(Grove).filter(Grove.name == grove_name).first()
|
||||
if not grove:
|
||||
raise HTTPException(status_code=404, detail="Grove not found")
|
||||
|
||||
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=404, detail="Tree not found")
|
||||
|
||||
grafts = db.query(Graft).filter(Graft.tree_id == tree.id).order_by(Graft.name).all()
|
||||
return grafts
|
||||
|
||||
|
||||
@router.post("/api/v1/grove/{grove_name}/{tree_name}/graft", response_model=GraftResponse)
|
||||
def create_graft(
|
||||
grove_name: str,
|
||||
tree_name: str,
|
||||
graft: GraftCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = get_user_id(request)
|
||||
|
||||
grove = db.query(Grove).filter(Grove.name == grove_name).first()
|
||||
if not grove:
|
||||
raise HTTPException(status_code=404, detail="Grove not found")
|
||||
|
||||
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=404, detail="Tree not found")
|
||||
|
||||
# Verify fruit exists
|
||||
fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first()
|
||||
if not fruit:
|
||||
raise HTTPException(status_code=404, detail="Fruit not found")
|
||||
|
||||
# Create or update graft
|
||||
existing = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == graft.name).first()
|
||||
if existing:
|
||||
existing.fruit_id = graft.fruit_id
|
||||
existing.created_by = user_id
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
db_graft = Graft(
|
||||
tree_id=tree.id,
|
||||
name=graft.name,
|
||||
fruit_id=graft.fruit_id,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(db_graft)
|
||||
db.commit()
|
||||
db.refresh(db_graft)
|
||||
return db_graft
|
||||
|
||||
|
||||
# Consumer routes
|
||||
@router.get("/api/v1/grove/{grove_name}/{tree_name}/consumers", response_model=List[ConsumerResponse])
|
||||
def get_consumers(grove_name: str, tree_name: str, db: Session = Depends(get_db)):
|
||||
grove = db.query(Grove).filter(Grove.name == grove_name).first()
|
||||
if not grove:
|
||||
raise HTTPException(status_code=404, detail="Grove not found")
|
||||
|
||||
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=404, detail="Tree not found")
|
||||
|
||||
consumers = db.query(Consumer).filter(Consumer.tree_id == tree.id).order_by(Consumer.last_access.desc()).all()
|
||||
return consumers
|
||||
|
||||
|
||||
# Fruit by ID
|
||||
@router.get("/api/v1/fruit/{fruit_id}", response_model=FruitResponse)
|
||||
def get_fruit(fruit_id: str, db: Session = Depends(get_db)):
|
||||
fruit = db.query(Fruit).filter(Fruit.id == fruit_id).first()
|
||||
if not fruit:
|
||||
raise HTTPException(status_code=404, detail="Fruit not found")
|
||||
return fruit
|
||||
101
backend/app/schemas.py
Normal file
101
backend/app/schemas.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# Grove schemas
|
||||
class GroveCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_public: bool = True
|
||||
|
||||
|
||||
class GroveResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str]
|
||||
is_public: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Tree schemas
|
||||
class TreeCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class TreeResponse(BaseModel):
|
||||
id: UUID
|
||||
grove_id: UUID
|
||||
name: str
|
||||
description: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Fruit schemas
|
||||
class FruitResponse(BaseModel):
|
||||
id: str
|
||||
size: int
|
||||
content_type: Optional[str]
|
||||
original_name: Optional[str]
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
ref_count: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Graft schemas
|
||||
class GraftCreate(BaseModel):
|
||||
name: str
|
||||
fruit_id: str
|
||||
|
||||
|
||||
class GraftResponse(BaseModel):
|
||||
id: UUID
|
||||
tree_id: UUID
|
||||
name: str
|
||||
fruit_id: str
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Cultivate response (upload)
|
||||
class CultivateResponse(BaseModel):
|
||||
fruit_id: str
|
||||
size: int
|
||||
grove: str
|
||||
tree: str
|
||||
tag: Optional[str]
|
||||
|
||||
|
||||
# Consumer schemas
|
||||
class ConsumerResponse(BaseModel):
|
||||
id: UUID
|
||||
tree_id: UUID
|
||||
project_url: str
|
||||
last_access: datetime
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Health check
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str = "1.0.0"
|
||||
83
backend/app/storage.py
Normal file
83
backend/app/storage.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import hashlib
|
||||
from typing import BinaryIO, Tuple
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class S3Storage:
|
||||
def __init__(self):
|
||||
config = Config(s3={"addressing_style": "path"} if settings.s3_use_path_style else {})
|
||||
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint if settings.s3_endpoint else None,
|
||||
region_name=settings.s3_region,
|
||||
aws_access_key_id=settings.s3_access_key_id,
|
||||
aws_secret_access_key=settings.s3_secret_access_key,
|
||||
config=config,
|
||||
)
|
||||
self.bucket = settings.s3_bucket
|
||||
|
||||
def store(self, file: BinaryIO) -> Tuple[str, int]:
|
||||
"""
|
||||
Store a file and return its SHA256 hash and size.
|
||||
Content-addressable: if the file already exists, just return the hash.
|
||||
"""
|
||||
# Read file and compute hash
|
||||
content = file.read()
|
||||
sha256_hash = hashlib.sha256(content).hexdigest()
|
||||
size = len(content)
|
||||
|
||||
# Check if already exists
|
||||
s3_key = f"fruits/{sha256_hash[:2]}/{sha256_hash[2:4]}/{sha256_hash}"
|
||||
|
||||
if not self._exists(s3_key):
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket,
|
||||
Key=s3_key,
|
||||
Body=content,
|
||||
)
|
||||
|
||||
return sha256_hash, size, s3_key
|
||||
|
||||
def get(self, s3_key: str) -> bytes:
|
||||
"""Retrieve a file by its S3 key"""
|
||||
response = self.client.get_object(Bucket=self.bucket, Key=s3_key)
|
||||
return response["Body"].read()
|
||||
|
||||
def get_stream(self, s3_key: str):
|
||||
"""Get a streaming response for a file"""
|
||||
response = self.client.get_object(Bucket=self.bucket, Key=s3_key)
|
||||
return response["Body"]
|
||||
|
||||
def _exists(self, s3_key: str) -> bool:
|
||||
"""Check if an object exists"""
|
||||
try:
|
||||
self.client.head_object(Bucket=self.bucket, Key=s3_key)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
def delete(self, s3_key: str) -> bool:
|
||||
"""Delete an object"""
|
||||
try:
|
||||
self.client.delete_object(Bucket=self.bucket, Key=s3_key)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_storage = None
|
||||
|
||||
|
||||
def get_storage() -> S3Storage:
|
||||
global _storage
|
||||
if _storage is None:
|
||||
_storage = S3Storage()
|
||||
return _storage
|
||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.13.1
|
||||
boto3==1.34.25
|
||||
python-multipart==0.0.6
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
Reference in New Issue
Block a user