// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "errors" "fmt" "net/http" "strings" "sync/atomic" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/adapter/kubernetes" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/pkg/api" ) // ProjectsHandler handles project-related endpoints. type ProjectsHandler struct { // Legacy dependencies (for backward compatibility) projectRepo *kubernetes.ProjectRepository executor *kubernetes.Executor streams *streamManager cmdID atomic.Uint64 // New hexagonal architecture dependencies projectService *service.ProjectService } // NewProjectsHandler creates a new projects handler with injected dependencies. func NewProjectsHandler(projectRepo *kubernetes.ProjectRepository, executor *kubernetes.Executor) *ProjectsHandler { return &ProjectsHandler{ projectRepo: projectRepo, executor: executor, streams: newStreamManager(), } } // NewProjectsHandlerWithService creates a new projects handler with injected service. func NewProjectsHandlerWithService(projectService *service.ProjectService) *ProjectsHandler { return &ProjectsHandler{ projectService: projectService, } } // Mount registers the projects routes. func (h *ProjectsHandler) Mount(r api.Router) { r.Route("/projects", func(r chi.Router) { r.Get("/", h.List) r.Get("/{id}", h.Get) r.Post("/{id}/claude", h.RunClaude) r.Post("/{id}/shell", h.RunShell) r.Post("/{id}/git", h.RunGit) r.Get("/{id}/events", h.Events) }) } // getAuditContext extracts audit-related information from the HTTP request. func getAuditContext(r *http.Request) *service.AuditContext { apiKey := auth.GetAPIKey(r.Context()) if apiKey == nil { return nil } return &service.AuditContext{ APIKeyID: string(apiKey.ID), ClientIP: getClientIP(r), UserAgent: r.UserAgent(), } } // getClientIP extracts the client IP from the request. func getClientIP(r *http.Request) string { // Check X-Forwarded-For header (set by proxies/load balancers) if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // Take the first IP in the chain if idx := strings.Index(xff, ","); idx != -1 { return strings.TrimSpace(xff[:idx]) } return strings.TrimSpace(xff) } // Check X-Real-IP header if xri := r.Header.Get("X-Real-IP"); xri != "" { return strings.TrimSpace(xri) } // Fall back to RemoteAddr addr := r.RemoteAddr // Handle IPv6 addresses like "[::1]:8080" if strings.HasPrefix(addr, "[") { if idx := strings.LastIndex(addr, "]:"); idx != -1 { return addr[1:idx] } return strings.Trim(addr, "[]") } // Handle IPv4 addresses like "192.168.1.1:8080" if idx := strings.LastIndex(addr, ":"); idx != -1 { return addr[:idx] } return addr } // List returns all available projects. // GET /projects func (h *ProjectsHandler) List(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() // Use new service if available if h.projectService != nil { projects, err := h.projectService.List(ctx) if err != nil { api.WriteInternalError(w, r, "failed to list projects") return } api.WriteSuccess(w, r, projects) return } // Legacy path using hexagonal types if h.projectRepo != nil { _ = h.projectRepo.RefreshStatus(ctx) projects, err := h.projectRepo.List(ctx) if err != nil { api.WriteInternalError(w, r, "failed to list projects") return } api.WriteSuccess(w, r, projects) return } api.WriteInternalError(w, r, "no project service configured") } // Get returns a specific project by ID. // GET /projects/{id} func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() // Use new service if available if h.projectService != nil { project, err := h.projectService.Get(ctx, domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } api.WriteSuccess(w, r, project) return } // Legacy path using hexagonal types if h.projectRepo != nil { _ = h.projectRepo.RefreshStatus(ctx) project, err := h.projectRepo.Get(ctx, domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } api.WriteSuccess(w, r, project) return } api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) } // Events streams command output via Server-Sent Events. // GET /projects/{id}/events // Supports Last-Event-ID header for reconnection with event replay. func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") streamID := r.URL.Query().Get("stream_id") lastEventID := r.Header.Get("Last-Event-ID") // Check project exists if h.projectService != nil { exists, err := h.projectService.Exists(r.Context(), domain.ProjectID(id)) if err != nil || !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } } else if h.projectRepo != nil { exists, err := h.projectRepo.Exists(r.Context(), domain.ProjectID(id)) if err != nil || !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } } else { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") flusher, ok := w.(http.Flusher) if !ok { api.WriteInternalError(w, r, "SSE not supported") return } // Subscribe to events - use service if available, with Last-Event-ID support var events <-chan port.StreamEvent var cleanup func() if h.projectService != nil { if lastEventID != "" { events, cleanup = h.projectService.SubscribeFromID(streamID, lastEventID) } else { events, cleanup = h.projectService.Subscribe(streamID) } } else { legacyEvents := h.streams.Subscribe(streamID) // Create adapter from legacy to port.StreamEvent with context cancellation portEvents := make(chan port.StreamEvent, 100) adapterCtx, adapterCancel := context.WithCancel(r.Context()) go func() { defer close(portEvents) for { select { case ev, ok := <-legacyEvents: if !ok { return } select { case portEvents <- port.StreamEvent{Type: ev.Type, Data: ev.Data}: case <-adapterCtx.Done(): return } case <-adapterCtx.Done(): return } } }() events = portEvents cleanup = func() { adapterCancel() h.streams.Unsubscribe(streamID, legacyEvents) } } defer cleanup() // Send initial connected event writeSSE(w, flusher, "connected", map[string]any{ "project": id, "stream_id": streamID, "reconnecting": lastEventID != "", }) // Stream events until client disconnects or stream closes ctx := r.Context() heartbeat := time.NewTicker(30 * time.Second) defer heartbeat.Stop() for { select { case <-ctx.Done(): return case event, ok := <-events: if !ok { return } // Include event ID in SSE output for reconnection support writeSSEWithID(w, flusher, event.ID, event.Type, event.Data) if event.Type == "complete" { return } case <-heartbeat.C: writeSSE(w, flusher, "heartbeat", map[string]any{ "timestamp": time.Now().UTC().Format(time.RFC3339), }) } } } // ProjectRepository returns the project repository for use by other handlers. func (h *ProjectsHandler) ProjectRepository() *kubernetes.ProjectRepository { return h.projectRepo } // Executor returns the executor for use by other handlers. func (h *ProjectsHandler) Executor() *kubernetes.Executor { return h.executor }