rdev/internal/handlers/components.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

217 lines
6.3 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// ComponentsHandler handles component management endpoints.
type ComponentsHandler struct {
service port.ComponentService
logger *slog.Logger
}
// NewComponentsHandler creates a new components handler.
func NewComponentsHandler(service port.ComponentService, logger *slog.Logger) *ComponentsHandler {
if logger == nil {
logger = slog.Default()
}
return &ComponentsHandler{service: service, logger: logger}
}
// Mount registers the component routes.
func (h *ComponentsHandler) Mount(r api.Router) {
r.Route("/projects/{id}/components", func(r chi.Router) {
r.Post("/", h.Add)
r.Get("/", h.List)
r.Delete("/*", h.Remove) // Wildcard to capture path like "services/auth-api"
})
}
// AddComponentRequest is the request body for POST /projects/{id}/components.
type AddComponentRequest struct {
Type string `json:"type"` // service, worker, app-astro, app-react, cli
Name string `json:"name"` // component name (slug format)
Template string `json:"template"` // optional: specific template variant
Port int `json:"port"` // optional: specific port (auto-assigned if 0)
}
// ComponentResponse is the response for component operations.
type ComponentResponse struct {
Type string `json:"type"`
Name string `json:"name"`
Path string `json:"path"`
Port int `json:"port"`
Template string `json:"template"`
Dependencies []string `json:"dependencies"`
}
// Add adds a new component to a project's monorepo.
// POST /projects/{id}/components
func (h *ComponentsHandler) Add(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
// Validate project ID
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.service == nil {
api.WriteInternalError(w, r, "component service not configured")
return
}
var req AddComponentRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Validate required fields
v := validate.New()
v.Required(req.Type, "type")
v.Required(req.Name, "name")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
component, err := h.service.AddComponent(ctx, projectID, port.AddComponentRequest{
Type: req.Type,
Name: req.Name,
Template: req.Template,
Port: req.Port,
})
if err != nil {
// Map domain errors to HTTP responses
switch {
case errors.Is(err, domain.ErrInvalidComponentType):
api.WriteBadRequest(w, r, err.Error())
case errors.Is(err, domain.ErrInvalidComponentName):
api.WriteBadRequest(w, r, err.Error())
case errors.Is(err, domain.ErrDuplicateComponent):
api.WriteError(w, r, http.StatusConflict, "CONFLICT", err.Error())
case errors.Is(err, domain.ErrProjectNotFound):
api.WriteNotFound(w, r, err.Error())
default:
h.logger.Error("failed to add component", "error", err, "project", projectID, "name", req.Name)
api.WriteInternalError(w, r, "failed to add component")
}
return
}
api.WriteCreated(w, r, ComponentResponse{
Type: string(component.Type),
Name: component.Name,
Path: component.Path,
Port: component.Port,
Template: component.Template,
Dependencies: component.Dependencies,
})
}
// List lists all components in a project's monorepo.
// GET /projects/{id}/components
func (h *ComponentsHandler) List(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// Validate project ID
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.service == nil {
api.WriteInternalError(w, r, "component service not configured")
return
}
components, err := h.service.ListComponents(ctx, projectID)
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, err.Error())
return
}
h.logger.Error("failed to list components", "error", err, "project", projectID)
api.WriteInternalError(w, r, "failed to list components")
return
}
// Convert to response format
response := make([]ComponentResponse, len(components))
for i, c := range components {
response[i] = ComponentResponse{
Type: string(c.Type),
Name: c.Name,
Path: c.Path,
Port: c.Port,
Template: c.Template,
Dependencies: c.Dependencies,
}
// Ensure dependencies is not nil for JSON
if response[i].Dependencies == nil {
response[i].Dependencies = []string{}
}
}
api.WriteSuccess(w, r, map[string]any{"components": response})
}
// Remove removes a component from a project's monorepo.
// DELETE /projects/{id}/components/{path}
func (h *ComponentsHandler) Remove(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
// Get the component path from the wildcard - chi captures everything after /components/
componentPath := chi.URLParam(r, "*")
if componentPath == "" {
api.WriteBadRequest(w, r, "component path is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// Validate project ID
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.service == nil {
api.WriteInternalError(w, r, "component service not configured")
return
}
err := h.service.RemoveComponent(ctx, projectID, componentPath)
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, err.Error())
return
}
if errors.Is(err, domain.ErrComponentNotFound) {
api.WriteNotFound(w, r, err.Error())
return
}
h.logger.Error("failed to remove component", "error", err, "project", projectID, "path", componentPath)
api.WriteInternalError(w, r, "failed to remove component")
return
}
api.WriteNoContent(w)
}