rdev/internal/handlers/projects.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

301 lines
8.1 KiB
Go

// 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(), TimeoutFastLookup)
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(), TimeoutFastLookup)
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
}