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>
217 lines
6.3 KiB
Go
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)
|
|
}
|