// Package api provides HTTP handlers for the fetch_ml API server package api import ( "encoding/json" "fmt" "net/http" "github.com/jfraeys/fetch_ml/internal/api/helpers" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/experiment" "github.com/jfraeys/fetch_ml/internal/jupyter" "github.com/jfraeys/fetch_ml/internal/logging" ) // Handlers groups all HTTP handlers type Handlers struct { expManager *experiment.Manager jupyterServiceMgr *jupyter.ServiceManager logger *logging.Logger } // NewHandlers creates a new handler group func NewHandlers( expManager *experiment.Manager, jupyterServiceMgr *jupyter.ServiceManager, logger *logging.Logger, ) *Handlers { return &Handlers{ expManager: expManager, jupyterServiceMgr: jupyterServiceMgr, logger: logger, } } // RegisterHandlers registers all HTTP handlers with the mux func (h *Handlers) RegisterHandlers(mux *http.ServeMux) { // Health check endpoints mux.HandleFunc("/db-status", h.handleDBStatus) // Jupyter service endpoints if h.jupyterServiceMgr != nil { mux.HandleFunc("/api/jupyter/services", h.handleJupyterServices) mux.HandleFunc("/api/jupyter/experiments/link", h.handleJupyterExperimentLink) mux.HandleFunc("/api/jupyter/experiments/sync", h.handleJupyterExperimentSync) } } // handleHealth responds with a simple health check func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = fmt.Fprintf(w, "OK\n") } // handleDBStatus responds with database connection status func (h *Handlers) handleDBStatus(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") // This would need the DB instance passed to handlers // For now, return a basic response response := map[string]any{ "status": "unknown", "message": "Database status check not implemented", } w.WriteHeader(http.StatusOK) if _, err := w.Write(helpers.MarshalJSONOrEmpty(response)); err != nil { h.logger.Error("failed to write response", "error", err) } } // handleJupyterServices handles Jupyter service management requests func (h *Handlers) handleJupyterServices(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "Unauthorized: No user context", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: if !user.HasPermission("jupyter:read") { http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden) return } h.listJupyterServices(w, r) case http.MethodPost: if !user.HasPermission("jupyter:manage") { http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden) return } h.startJupyterService(w, r) case http.MethodDelete: if !user.HasPermission("jupyter:manage") { http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden) return } h.stopJupyterService(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } // listJupyterServices lists all Jupyter services func (h *Handlers) listJupyterServices(w http.ResponseWriter, _ *http.Request) { services := h.jupyterServiceMgr.ListServices() w.WriteHeader(http.StatusOK) if _, err := w.Write(helpers.MarshalJSONOrEmpty(services)); err != nil { h.logger.Error("failed to write response", "error", err) } } // startJupyterService starts a new Jupyter service func (h *Handlers) startJupyterService(w http.ResponseWriter, r *http.Request) { var req jupyter.StartRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } ctx := r.Context() service, err := h.jupyterServiceMgr.StartService(ctx, &req) if err != nil { http.Error(w, fmt.Sprintf("Failed to start service: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) if _, err := w.Write(helpers.MarshalJSONOrEmpty(service)); err != nil { h.logger.Error("failed to write response", "error", err) } } // stopJupyterService stops a Jupyter service func (h *Handlers) stopJupyterService(w http.ResponseWriter, r *http.Request) { serviceID := r.URL.Query().Get("id") if serviceID == "" { http.Error(w, "Service ID is required", http.StatusBadRequest) return } ctx := r.Context() if err := h.jupyterServiceMgr.StopService(ctx, serviceID); err != nil { http.Error(w, fmt.Sprintf("Failed to stop service: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]string{ "status": "stopped", "id": serviceID, }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // handleJupyterExperimentLink handles linking Jupyter workspaces with experiments func (h *Handlers) handleJupyterExperimentLink(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "Unauthorized: No user context", http.StatusUnauthorized) return } if !user.HasPermission("jupyter:manage") { http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Workspace string `json:"workspace"` ExperimentID string `json:"experiment_id"` ServiceID string `json:"service_id,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Workspace == "" || req.ExperimentID == "" { http.Error(w, "Workspace and experiment_id are required", http.StatusBadRequest) return } if !h.expManager.ExperimentExists(req.ExperimentID) { http.Error(w, "Experiment not found", http.StatusNotFound) return } // Link workspace with experiment using service manager if err := h.jupyterServiceMgr.LinkWorkspaceWithExperiment( req.Workspace, req.ExperimentID, req.ServiceID, ); err != nil { http.Error(w, fmt.Sprintf("Failed to link workspace: %v", err), http.StatusInternalServerError) return } // Get workspace metadata to return metadata, err := h.jupyterServiceMgr.GetWorkspaceMetadata(req.Workspace) if err != nil { http.Error( w, fmt.Sprintf("Failed to get workspace metadata: %v", err), http.StatusInternalServerError, ) return } h.logger.Info("jupyter workspace linked with experiment", "workspace", req.Workspace, "experiment_id", req.ExperimentID, "service_id", req.ServiceID) w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(map[string]interface{}{ "status": "linked", "data": metadata, }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // handleJupyterExperimentSync handles synchronization between Jupyter workspaces and experiments func (h *Handlers) handleJupyterExperimentSync(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "Unauthorized: No user context", http.StatusUnauthorized) return } if !user.HasPermission("jupyter:manage") { http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Workspace string `json:"workspace"` ExperimentID string `json:"experiment_id"` Direction string `json:"direction"` // "pull" or "push" SyncType string `json:"sync_type"` // "data", "notebooks", "all" } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Workspace == "" || req.ExperimentID == "" || req.Direction == "" { http.Error(w, "Workspace, experiment_id, and direction are required", http.StatusBadRequest) return } // Validate experiment exists if !h.expManager.ExperimentExists(req.ExperimentID) { http.Error(w, "Experiment not found", http.StatusNotFound) return } // Perform sync operation using service manager ctx := r.Context() if err := h.jupyterServiceMgr.SyncWorkspaceWithExperiment( ctx, req.Workspace, req.ExperimentID, req.Direction); err != nil { http.Error(w, fmt.Sprintf("Failed to sync workspace: %v", err), http.StatusInternalServerError) return } // Get updated metadata metadata, err := h.jupyterServiceMgr.GetWorkspaceMetadata(req.Workspace) if err != nil { http.Error( w, fmt.Sprintf("Failed to get workspace metadata: %v", err), http.StatusInternalServerError, ) return } // Create sync result syncResult := map[string]interface{}{ "workspace": req.Workspace, "experiment_id": req.ExperimentID, "direction": req.Direction, "sync_type": req.SyncType, "synced_at": metadata.LastSync, "status": "completed", "metadata": metadata, } h.logger.Info("jupyter workspace sync completed", "workspace", req.Workspace, "experiment_id", req.ExperimentID, "direction", req.Direction, "sync_type", req.SyncType) w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(syncResult); err != nil { h.logger.Error("failed to encode response", "error", err) } }