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>
358 lines
9.6 KiB
Go
358 lines
9.6 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/auth"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/sanitize"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
// QueueHandler handles command queue endpoints.
|
|
type QueueHandler struct {
|
|
queue port.CommandQueue
|
|
projects port.ProjectRepository
|
|
}
|
|
|
|
// NewQueueHandler creates a new queue handler.
|
|
func NewQueueHandler(queue port.CommandQueue, projects port.ProjectRepository) *QueueHandler {
|
|
return &QueueHandler{
|
|
queue: queue,
|
|
projects: projects,
|
|
}
|
|
}
|
|
|
|
// Mount registers the queue routes.
|
|
func (h *QueueHandler) Mount(r api.Router) {
|
|
r.Route("/projects/{id}/queue", func(r chi.Router) {
|
|
r.Post("/", h.Enqueue)
|
|
r.Get("/", h.List)
|
|
r.Get("/stats", h.Stats)
|
|
r.Get("/{cmdId}", h.GetByID)
|
|
r.Delete("/{cmdId}", h.Cancel)
|
|
})
|
|
}
|
|
|
|
// EnqueueRequest is the request body for POST /projects/{id}/queue.
|
|
type EnqueueRequest struct {
|
|
Command string `json:"command"` // Required: the command to execute
|
|
CommandType string `json:"command_type"` // Required: claude, shell, or git
|
|
WorkingDir string `json:"working_dir,omitempty"` // Optional: working directory
|
|
Priority int `json:"priority,omitempty"` // Optional: higher = more urgent (default: 0)
|
|
}
|
|
|
|
// MaxCommandSize is the maximum allowed size for command payloads (10KB).
|
|
const MaxCommandSize = 10 * 1024
|
|
|
|
// EnqueueResponse is the response for POST /projects/{id}/queue.
|
|
type EnqueueResponse struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
Status string `json:"status"`
|
|
StreamURL string `json:"stream_url"`
|
|
Position int `json:"position,omitempty"` // Approximate queue position
|
|
}
|
|
|
|
// Enqueue adds a command to the project's queue.
|
|
// POST /projects/{id}/queue
|
|
func (h *QueueHandler) Enqueue(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Parse request
|
|
var req EnqueueRequest
|
|
if err := api.DecodeJSON(r, &req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Validate command type
|
|
var cmdType domain.CommandType
|
|
switch req.CommandType {
|
|
case "claude":
|
|
cmdType = domain.CommandTypeClaude
|
|
case "shell":
|
|
cmdType = domain.CommandTypeShell
|
|
case "git":
|
|
cmdType = domain.CommandTypeGit
|
|
default:
|
|
api.WriteBadRequest(w, r, "command_type must be one of: claude, shell, git")
|
|
return
|
|
}
|
|
|
|
// Validate command
|
|
if req.Command == "" {
|
|
api.WriteBadRequest(w, r, "command is required")
|
|
return
|
|
}
|
|
|
|
// Validate command size to prevent large payloads
|
|
if len(req.Command) > MaxCommandSize {
|
|
api.WriteBadRequest(w, r, fmt.Sprintf("command exceeds maximum size of %d bytes", MaxCommandSize))
|
|
return
|
|
}
|
|
|
|
// Sanitize based on command type
|
|
switch cmdType {
|
|
case domain.CommandTypeClaude:
|
|
if err := sanitize.ClaudePrompt(req.Command); err != nil {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
case domain.CommandTypeShell:
|
|
if err := sanitize.ShellCommand(req.Command); err != nil {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
case domain.CommandTypeGit:
|
|
// For git, the command should be JSON-encoded args
|
|
var gitArgs []string
|
|
if err := json.Unmarshal([]byte(req.Command), &gitArgs); err != nil {
|
|
api.WriteBadRequest(w, r, "git command must be JSON array of args")
|
|
return
|
|
}
|
|
if err := sanitize.GitArgs(gitArgs); err != nil {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get API key ID for audit trail
|
|
var apiKeyID string
|
|
if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil {
|
|
apiKeyID = string(apiKey.ID)
|
|
}
|
|
|
|
// Create queued command
|
|
cmd := &domain.QueuedCommand{
|
|
ProjectID: projectID,
|
|
Command: req.Command,
|
|
CommandType: cmdType,
|
|
WorkingDir: req.WorkingDir,
|
|
Status: domain.QueueStatusPending,
|
|
Priority: req.Priority,
|
|
APIKeyID: apiKeyID,
|
|
}
|
|
|
|
// Enqueue
|
|
if err := h.queue.Enqueue(r.Context(), cmd); err != nil {
|
|
api.WriteInternalError(w, r, "failed to enqueue command")
|
|
return
|
|
}
|
|
|
|
// Get approximate queue position
|
|
pendingStatus := domain.QueueStatusPending
|
|
pending, _ := h.queue.List(r.Context(), projectID, &domain.QueueFilters{
|
|
Status: &pendingStatus,
|
|
Limit: 1000,
|
|
SortOrder: "asc",
|
|
})
|
|
position := len(pending)
|
|
|
|
api.WriteCreated(w, r, EnqueueResponse{
|
|
ID: string(cmd.ID),
|
|
ProjectID: projectID,
|
|
Status: string(cmd.Status),
|
|
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", projectID, cmd.ID),
|
|
Position: position,
|
|
})
|
|
}
|
|
|
|
// ListResponse is the response for GET /projects/{id}/queue.
|
|
type ListResponse struct {
|
|
Commands []*domain.QueuedCommand `json:"commands"`
|
|
Total int `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
// List returns queued commands for a project.
|
|
// GET /projects/{id}/queue
|
|
func (h *QueueHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Parse query params
|
|
filters := domain.DefaultQueueFilters()
|
|
|
|
if status := r.URL.Query().Get("status"); status != "" {
|
|
s := domain.QueueStatus(status)
|
|
filters.Status = &s
|
|
}
|
|
|
|
if limit := r.URL.Query().Get("limit"); limit != "" {
|
|
if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 1000 {
|
|
filters.Limit = l
|
|
}
|
|
}
|
|
|
|
if offset := r.URL.Query().Get("offset"); offset != "" {
|
|
if o, err := strconv.Atoi(offset); err == nil && o >= 0 {
|
|
filters.Offset = o
|
|
}
|
|
}
|
|
|
|
if sort := r.URL.Query().Get("sort"); sort == "asc" || sort == "desc" {
|
|
filters.SortOrder = sort
|
|
}
|
|
|
|
// List commands
|
|
commands, err := h.queue.List(r.Context(), projectID, filters)
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to list commands")
|
|
return
|
|
}
|
|
|
|
if commands == nil {
|
|
commands = []*domain.QueuedCommand{}
|
|
}
|
|
|
|
api.WriteSuccess(w, r, ListResponse{
|
|
Commands: commands,
|
|
Total: len(commands),
|
|
Limit: filters.Limit,
|
|
Offset: filters.Offset,
|
|
})
|
|
}
|
|
|
|
// GetByID returns a specific queued command.
|
|
// GET /projects/{id}/queue/{cmdId}
|
|
func (h *QueueHandler) GetByID(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
cmdID := chi.URLParam(r, "cmdId")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Get command
|
|
cmd, err := h.queue.GetByID(r.Context(), domain.QueuedCommandID(cmdID))
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrCommandNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("command not found: %s", cmdID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get command")
|
|
return
|
|
}
|
|
|
|
// Verify command belongs to project
|
|
if cmd.ProjectID != projectID {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("command not found: %s", cmdID))
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, cmd)
|
|
}
|
|
|
|
// Cancel cancels a pending queued command.
|
|
// DELETE /projects/{id}/queue/{cmdId}
|
|
func (h *QueueHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
cmdID := chi.URLParam(r, "cmdId")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Verify command exists and belongs to project
|
|
cmd, err := h.queue.GetByID(r.Context(), domain.QueuedCommandID(cmdID))
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrCommandNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("command not found: %s", cmdID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get command")
|
|
return
|
|
}
|
|
|
|
if cmd.ProjectID != projectID {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("command not found: %s", cmdID))
|
|
return
|
|
}
|
|
|
|
// Cancel command
|
|
if err := h.queue.Cancel(r.Context(), domain.QueuedCommandID(cmdID)); err != nil {
|
|
if errors.Is(err, domain.ErrCommandNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("command not found: %s", cmdID))
|
|
return
|
|
}
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"id": cmdID,
|
|
"status": "cancelled",
|
|
"message": "command cancelled successfully",
|
|
})
|
|
}
|
|
|
|
// Stats returns queue statistics for a project.
|
|
// GET /projects/{id}/queue/stats
|
|
func (h *QueueHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Get stats
|
|
stats, err := h.queue.GetStats(r.Context(), projectID)
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to get queue stats")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, stats)
|
|
}
|