Major changes: - Add internal/logging package with field constants, context propagation, sensitive data auto-redaction, and per-component log levels - Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.) - Extend SDLC with callback handlers, generate endpoints, and executor - Add new cookbook trees for aeries and slackpath progression - Add skeleton templates for queue, realtime, and microservices - Add worker component template with async job processing - Refactor services and handlers to use new logging infrastructure - Split component.go into component_infra.go and component_listing.go Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
263 lines
8.1 KiB
Go
263 lines
8.1 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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/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)).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)
|
|
}
|
|
|
|
// 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)
|
|
}
|