rdev/internal/handlers/components.go
jordan 56e3f83955 feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements
- Add auth.RequireScope() to all handler routes for proper authorization
- Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator)
- Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog)
- Add artifact_test.go for SDLC artifact coverage
- Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w
- Fix error wrapping to use %w instead of %v throughout codebase
- Improve CLI merge command with conflict detection and resolution
- Fix handler tests to include auth middleware for RequireScope
- Add cookbook tree runner scripts for automated testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 13:55:50 -07:00

262 lines
7.8 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/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// ComponentsHandler handles component management endpoints.
type ComponentsHandler struct {
service port.ComponentService
operationService *service.OperationService
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}
}
// SetOperationService sets the operation tracking service.
func (h *ComponentsHandler) SetOperationService(svc *service.OperationService) *ComponentsHandler {
if svc != nil {
h.operationService = svc
}
return h
}
// Mount registers the component routes.
func (h *ComponentsHandler) Mount(r api.Router) {
r.Route("/projects/{id}/components", func(r chi.Router) {
// Read operations
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.List)
// Write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/", h.Add)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/*", h.Remove)
})
}
// 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
}
// Start operation tracking
var operationID string
if h.operationService != nil {
operationID, _ = h.operationService.StartOperation(ctx, projectID,
domain.OperationTypeComponentAdd,
map[string]any{"type": req.Type, "name": req.Name, "template": req.Template, "port": req.Port},
r.Header.Get("X-Request-ID"))
}
component, err := h.service.AddComponent(ctx, projectID, port.AddComponentRequest{
Type: req.Type,
Name: req.Name,
Template: req.Template,
Port: req.Port,
})
if err != nil {
if h.operationService != nil && operationID != "" {
if opErr := h.operationService.FailOperation(ctx, operationID, err.Error(), ""); opErr != nil {
h.logger.Error("failed to record operation failure", "error", opErr, "operation_id", operationID)
}
}
// 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
}
if h.operationService != nil && operationID != "" {
if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{
"path": component.Path,
"port": component.Port,
}); opErr != nil {
h.logger.Error("failed to record operation completion", "error", opErr, "operation_id", operationID)
}
}
deps := component.Dependencies
if deps == nil {
deps = []string{}
}
resp := map[string]any{
"type": string(component.Type),
"name": component.Name,
"path": component.Path,
"port": component.Port,
"template": component.Template,
"dependencies": deps,
}
if operationID != "" {
resp["operation_id"] = operationID
}
api.WriteCreated(w, r, resp)
}
// 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)
}