// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "errors" "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/logging" "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 } // NewComponentsHandler creates a new components handler. func NewComponentsHandler(service port.ComponentService) *ComponentsHandler { return &ComponentsHandler{service: service} } // 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)).Post("/batch", h.AddBatch) 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 { log := logging.FromContext(ctx).WithHandler("Add") log.Error("failed to record operation failure", logging.FieldError, opErr.Error(), logging.FieldOperation, 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: log := logging.FromContext(ctx).WithHandler("Add") log.Error("failed to add component", logging.FieldError, err.Error(), logging.FieldProjectID, 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 { log := logging.FromContext(ctx).WithHandler("Add") log.Error("failed to record operation completion", logging.FieldError, opErr.Error(), logging.FieldOperation, 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) } // AddComponentBatchRequest is the request body for POST /projects/{id}/components/batch. type AddComponentBatchRequest struct { Components []AddComponentRequest `json:"components"` } // AddBatch adds multiple components to a project's monorepo in a single atomic operation. // POST /projects/{id}/components/batch func (h *ComponentsHandler) AddBatch(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") ctx, cancel := context.WithTimeout(r.Context(), TimeoutLongRunning) 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 AddComponentBatchRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Validate we have at least one component if len(req.Components) == 0 { api.WriteBadRequest(w, r, "at least one component is required") return } // Validate each component's required fields for i, comp := range req.Components { v := validate.New() v.Required(comp.Type, "components["+strconv.Itoa(i)+"].type") v.Required(comp.Name, "components["+strconv.Itoa(i)+"].name") if err := v.Error(); err != nil { api.WriteBadRequest(w, r, err.Error()) return } } // Convert to port requests portReqs := make([]port.AddComponentRequest, len(req.Components)) for i, comp := range req.Components { portReqs[i] = port.AddComponentRequest{ Type: comp.Type, Name: comp.Name, Template: comp.Template, Port: comp.Port, } } // Start operation tracking var operationID string if h.operationService != nil { componentNames := make([]string, len(req.Components)) for i, c := range req.Components { componentNames[i] = c.Type + "/" + c.Name } operationID, _ = h.operationService.StartOperation(ctx, projectID, domain.OperationTypeComponentAdd, map[string]any{"batch": true, "components": componentNames}, r.Header.Get("X-Request-ID")) } components, err := h.service.AddComponentBatch(ctx, projectID, portReqs) if err != nil { if h.operationService != nil && operationID != "" { if opErr := h.operationService.FailOperation(ctx, operationID, err.Error(), ""); opErr != nil { log := logging.FromContext(ctx).WithHandler("AddBatch") log.Error("failed to record operation failure", logging.FieldError, opErr.Error(), logging.FieldOperation, 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: log := logging.FromContext(ctx).WithHandler("AddBatch") log.Error("failed to add components", logging.FieldError, err.Error(), logging.FieldProjectID, projectID) api.WriteInternalError(w, r, "failed to add components") } return } if h.operationService != nil && operationID != "" { paths := make([]string, len(components)) for i, c := range components { paths[i] = c.Path } if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{ "paths": paths, "count": len(components), }); opErr != nil { log := logging.FromContext(ctx).WithHandler("AddBatch") log.Error("failed to record operation completion", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } } // Convert to response format response := make([]ComponentResponse, len(components)) for i, c := range components { deps := c.Dependencies if deps == nil { deps = []string{} } response[i] = ComponentResponse{ Type: string(c.Type), Name: c.Name, Path: c.Path, Port: c.Port, Template: c.Template, Dependencies: deps, } } resp := map[string]any{ "components": response, } 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 } log := logging.FromContext(ctx).WithHandler("List") log.Error("failed to list components", logging.FieldError, err.Error(), logging.FieldProjectID, 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 } log := logging.FromContext(ctx).WithHandler("Remove") log.Error("failed to remove component", logging.FieldError, err.Error(), logging.FieldProjectID, projectID, "path", componentPath) api.WriteInternalError(w, r, "failed to remove component") return } api.WriteNoContent(w) }