rdev/internal/handlers/projects.go
jordan 4a042a8b71 feat: Add rdev-api Go server with OpenAPI docs
Implements a fully documented API server following the aeries chassis pattern:

- pkg/api: Simplified chassis with App, Response helpers, and OpenAPI builder
- cmd/rdev-api: Entry point with full OpenAPI spec for all v0.4 endpoints
- internal/handlers: Stubbed project handlers (list, get, claude, shell, git, events)

Endpoints:
- GET  /health, /ready     - Health checks
- GET  /docs, /openapi.json - Scalar API docs
- GET  /projects           - List projects
- GET  /projects/{id}      - Get project
- POST /projects/{id}/claude, shell, git - Run commands
- GET  /projects/{id}/events - SSE streaming

Uses Scalar for dark-mode API documentation at /docs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 20:56:27 -07:00

193 lines
5.0 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/pkg/api"
)
// ProjectsHandler handles project-related endpoints.
type ProjectsHandler struct{}
// NewProjectsHandler creates a new projects handler.
func NewProjectsHandler() *ProjectsHandler {
return &ProjectsHandler{}
}
// 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)
})
}
// List returns all available projects.
// GET /projects
func (h *ProjectsHandler) List(w http.ResponseWriter, r *http.Request) {
// TODO: Implement project discovery from K8s
projects := []map[string]any{
{
"id": "pantheon",
"name": "Pantheon",
"description": "Go API backend",
"pod": "claudebox-pantheon-0",
"status": "running",
},
{
"id": "aeries",
"name": "Aeries",
"description": "Note community platform",
"pod": "claudebox-aeries-0",
"status": "running",
},
}
api.WriteSuccess(w, r, projects)
}
// 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")
// TODO: Look up project from registry
project := map[string]any{
"id": id,
"name": id,
"description": "Project description",
"pod": "claudebox-" + id + "-0",
"status": "running",
"workspace": "/workspace",
"config": map[string]any{
"claude_auth": true,
"git_enabled": true,
"last_command": nil,
},
}
api.WriteSuccess(w, r, project)
}
// RunClaude executes a Claude command in the project's claudebox.
// POST /projects/{id}/claude
func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// TODO: Parse request body for prompt
// TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- claude "{prompt}"
result := map[string]any{
"id": "cmd-" + id + "-001",
"project": id,
"type": "claude",
"status": "queued",
"stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-001",
}
api.WriteCreated(w, r, result)
}
// RunShell executes a shell command in the project's claudebox.
// POST /projects/{id}/shell
func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// TODO: Parse request body for command
// TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- bash -c "{command}"
result := map[string]any{
"id": "cmd-" + id + "-002",
"project": id,
"type": "shell",
"status": "queued",
"stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-002",
}
api.WriteCreated(w, r, result)
}
// RunGit executes a git command in the project's claudebox.
// POST /projects/{id}/git
func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// TODO: Parse request body for git command
// TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- git {args}
result := map[string]any{
"id": "cmd-" + id + "-003",
"project": id,
"type": "git",
"status": "queued",
"stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-003",
}
api.WriteCreated(w, r, result)
}
// Events streams command output via Server-Sent Events.
// GET /projects/{id}/events
func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
streamID := r.URL.Query().Get("stream_id")
// 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 {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
// TODO: Stream actual command output
// For now, send mock events
// Send initial connected event
writeSSE(w, flusher, "connected", map[string]any{
"project": id,
"stream_id": streamID,
})
// Send mock output
writeSSE(w, flusher, "output", map[string]any{
"line": "Starting command execution...",
"stream": "stdout",
})
writeSSE(w, flusher, "output", map[string]any{
"line": "Command completed successfully.",
"stream": "stdout",
})
// Send complete event
writeSSE(w, flusher, "complete", map[string]any{
"exit_code": 0,
"duration_ms": 1234,
})
}
// writeSSE writes a Server-Sent Event.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) {
dataBytes, _ := jsonMarshal(data)
w.Write([]byte("event: " + event + "\n"))
w.Write([]byte("data: " + string(dataBytes) + "\n\n"))
flusher.Flush()
}
// jsonMarshal is a simple JSON marshal helper.
func jsonMarshal(v any) ([]byte, error) {
return json.Marshal(v)
}