Weeks 1-7 of the template upgrade plan: - pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders - skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client - skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware) - components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth - cookbooks/feature-development.md with test and validation scripts - Handler tests for components, project management, and woodpecker webhook - 3 rounds of code review fixes applied Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
369 lines
11 KiB
Go
369 lines
11 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/service"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
// ProjectManagementHandler handles project lifecycle operations.
|
|
type ProjectManagementHandler struct {
|
|
infraService *service.ProjectInfraService
|
|
operationService *service.OperationService
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewProjectManagementHandler creates a new project management handler.
|
|
func NewProjectManagementHandler(infraService *service.ProjectInfraService, logger *slog.Logger) *ProjectManagementHandler {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &ProjectManagementHandler{
|
|
infraService: infraService,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// SetOperationService sets the operation tracking service.
|
|
func (h *ProjectManagementHandler) SetOperationService(svc *service.OperationService) *ProjectManagementHandler {
|
|
if svc != nil {
|
|
h.operationService = svc
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Mount registers the project management routes.
|
|
func (h *ProjectManagementHandler) Mount(r api.Router) {
|
|
r.Route("/project", func(r chi.Router) {
|
|
r.Post("/", h.Create) // POST /project - Create new project
|
|
r.Get("/", h.List) // GET /project - List all projects
|
|
r.Get("/{name}", h.Status) // GET /project/{name} - Get project status
|
|
r.Delete("/{name}", h.Delete) // DELETE /project/{name} - Delete project
|
|
})
|
|
|
|
// Template endpoints
|
|
r.Get("/templates", h.ListTemplates) // GET /templates - List available templates
|
|
r.Get("/templates/components", h.ListComponentTemplates) // GET /templates/components - List component templates
|
|
r.Get("/templates/{name}", h.GetTemplate) // GET /templates/{name} - Get template details
|
|
}
|
|
|
|
// CreateRequest is the request body for POST /project.
|
|
type CreateRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Private bool `json:"private,omitempty"`
|
|
Template string `json:"template,omitempty"` // Template to seed repo (default: "default")
|
|
}
|
|
|
|
// Create creates a new project with git repo and DNS.
|
|
// POST /project
|
|
func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutOrchestration)
|
|
defer cancel()
|
|
|
|
if h.infraService == nil {
|
|
api.WriteInternalError(w, r, "project infrastructure service not configured")
|
|
return
|
|
}
|
|
|
|
var req CreateRequest
|
|
if err := api.DecodeJSON(r, &req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
api.WriteBadRequest(w, r, "name is required")
|
|
return
|
|
}
|
|
|
|
// Start operation tracking
|
|
var operationID string
|
|
if h.operationService != nil {
|
|
operationID, _ = h.operationService.StartOperation(ctx, req.Name,
|
|
domain.OperationTypeProjectCreate,
|
|
map[string]any{"name": req.Name, "description": req.Description, "template": req.Template},
|
|
r.Header.Get("X-Request-ID"))
|
|
}
|
|
|
|
result, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Private: req.Private,
|
|
Template: req.Template,
|
|
})
|
|
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)
|
|
}
|
|
}
|
|
// Check for validation errors (user input) vs internal errors
|
|
if errors.Is(err, domain.ErrInvalidProjectName) {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
// Log internal errors but return generic message to client
|
|
h.logger.Error("project creation failed", "error", err, "name", req.Name)
|
|
api.WriteInternalError(w, r, "failed to create project")
|
|
return
|
|
}
|
|
|
|
if h.operationService != nil && operationID != "" {
|
|
if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{
|
|
"project_id": result.ProjectID,
|
|
"git_url": result.CloneHTTP,
|
|
}); opErr != nil {
|
|
h.logger.Error("failed to record operation completion", "error", opErr, "operation_id", operationID)
|
|
}
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"project_id": result.ProjectID,
|
|
"name": result.Name,
|
|
"description": result.Description,
|
|
"git": map[string]string{
|
|
"owner": result.GitRepoOwner,
|
|
"name": result.GitRepoName,
|
|
"clone_ssh": result.CloneSSH,
|
|
"clone_http": result.CloneHTTP,
|
|
"html_url": result.HTMLURL,
|
|
},
|
|
"domain": result.Domain,
|
|
"url": result.URL,
|
|
"next_steps": result.NextSteps,
|
|
}
|
|
if operationID != "" {
|
|
resp["operation_id"] = operationID
|
|
}
|
|
api.WriteCreated(w, r, resp)
|
|
}
|
|
|
|
// List returns all projects.
|
|
// GET /project
|
|
func (h *ProjectManagementHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
|
|
defer cancel()
|
|
|
|
if h.infraService == nil {
|
|
api.WriteInternalError(w, r, "project infrastructure service not configured")
|
|
return
|
|
}
|
|
|
|
projects, err := h.infraService.ListProjects(ctx)
|
|
if err != nil {
|
|
h.logger.Error("failed to list projects", "error", err)
|
|
api.WriteInternalError(w, r, "failed to list projects")
|
|
return
|
|
}
|
|
|
|
// Convert to response format
|
|
response := make([]map[string]any, len(projects))
|
|
for i, p := range projects {
|
|
response[i] = map[string]any{
|
|
"project_id": p.ProjectID,
|
|
"name": p.Name,
|
|
"description": p.Description,
|
|
"git": map[string]string{
|
|
"clone_ssh": p.CloneSSH,
|
|
"clone_http": p.CloneHTTP,
|
|
"html_url": p.HTMLURL,
|
|
},
|
|
"domain": p.Domain,
|
|
"url": p.URL,
|
|
"deployment": map[string]any{
|
|
"status": p.DeploymentStatus,
|
|
"image": p.DeploymentImage,
|
|
"replicas": p.DeploymentReplicas,
|
|
"ready_replicas": p.ReadyReplicas,
|
|
},
|
|
}
|
|
}
|
|
|
|
api.WriteSuccess(w, r, response)
|
|
}
|
|
|
|
// Status returns the status of a specific project.
|
|
// GET /project/{name}
|
|
func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
|
|
defer cancel()
|
|
|
|
if h.infraService == nil {
|
|
api.WriteInternalError(w, r, "project infrastructure service not configured")
|
|
return
|
|
}
|
|
|
|
status, err := h.infraService.GetStatus(ctx, name)
|
|
if err != nil {
|
|
// Check if it's a "not found" error
|
|
if errors.Is(err, domain.ErrProjectNotFound) {
|
|
api.WriteNotFound(w, r, "project not found")
|
|
return
|
|
}
|
|
h.logger.Error("failed to get project status", "error", err, "name", name)
|
|
api.WriteInternalError(w, r, "failed to get project status")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"project_id": status.ProjectID,
|
|
"name": status.Name,
|
|
"description": status.Description,
|
|
"git": map[string]string{
|
|
"owner": status.GitRepoOwner,
|
|
"name": status.GitRepoName,
|
|
"clone_ssh": status.CloneSSH,
|
|
"clone_http": status.CloneHTTP,
|
|
"html_url": status.HTMLURL,
|
|
},
|
|
"domain": status.Domain,
|
|
"custom_domain": status.CustomDomain,
|
|
"url": status.URL,
|
|
"deployment": map[string]any{
|
|
"status": status.DeploymentStatus,
|
|
"image": status.DeploymentImage,
|
|
"replicas": status.DeploymentReplicas,
|
|
"ready_replicas": status.ReadyReplicas,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Delete removes a project and its associated resources.
|
|
// DELETE /project/{name}
|
|
func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
|
defer cancel()
|
|
|
|
if h.infraService == nil {
|
|
api.WriteInternalError(w, r, "project infrastructure service not configured")
|
|
return
|
|
}
|
|
|
|
err := h.infraService.DeleteProject(ctx, name)
|
|
if err != nil {
|
|
// Check if it's a "not found" error
|
|
if errors.Is(err, domain.ErrProjectNotFound) {
|
|
api.WriteNotFound(w, r, "project not found")
|
|
return
|
|
}
|
|
h.logger.Error("failed to delete project", "error", err, "name", name)
|
|
api.WriteInternalError(w, r, "failed to delete project")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]string{
|
|
"status": "deleted",
|
|
"project": name,
|
|
})
|
|
}
|
|
|
|
// ListTemplates returns available project templates.
|
|
// GET /templates
|
|
func (h *ProjectManagementHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
|
|
defer cancel()
|
|
|
|
if h.infraService == nil {
|
|
api.WriteInternalError(w, r, "project infrastructure service not configured")
|
|
return
|
|
}
|
|
|
|
templates, err := h.infraService.ListTemplates(ctx)
|
|
if err != nil {
|
|
h.logger.Error("failed to list templates", "error", err)
|
|
api.WriteInternalError(w, r, "failed to list templates")
|
|
return
|
|
}
|
|
|
|
// Convert to response format
|
|
response := make([]map[string]any, len(templates))
|
|
for i, t := range templates {
|
|
response[i] = map[string]any{
|
|
"name": t.Name,
|
|
"description": t.Description,
|
|
"stack": t.Stack,
|
|
"files": t.Files,
|
|
}
|
|
}
|
|
|
|
api.WriteSuccess(w, r, response)
|
|
}
|
|
|
|
// GetTemplate returns details about a specific template.
|
|
// GET /templates/{name}
|
|
func (h *ProjectManagementHandler) GetTemplate(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
|
|
defer cancel()
|
|
|
|
if h.infraService == nil {
|
|
api.WriteInternalError(w, r, "project infrastructure service not configured")
|
|
return
|
|
}
|
|
|
|
template, err := h.infraService.GetTemplate(ctx, name)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrTemplateNotFound) {
|
|
api.WriteNotFound(w, r, "template not found")
|
|
return
|
|
}
|
|
h.logger.Error("failed to get template", "error", err, "name", name)
|
|
api.WriteInternalError(w, r, "failed to get template")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"name": template.Name,
|
|
"description": template.Description,
|
|
"stack": template.Stack,
|
|
"files": template.Files,
|
|
})
|
|
}
|
|
|
|
// ListComponentTemplates returns available component templates.
|
|
// GET /templates/components
|
|
func (h *ProjectManagementHandler) ListComponentTemplates(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
|
|
defer cancel()
|
|
|
|
if h.infraService == nil {
|
|
api.WriteInternalError(w, r, "project infrastructure service not configured")
|
|
return
|
|
}
|
|
|
|
componentType := r.URL.Query().Get("type")
|
|
|
|
components, err := h.infraService.ListComponentTemplates(ctx, componentType)
|
|
if err != nil {
|
|
h.logger.Error("failed to list component templates", "error", err)
|
|
api.WriteInternalError(w, r, "failed to list component templates")
|
|
return
|
|
}
|
|
|
|
response := make([]map[string]any, len(components))
|
|
for i, c := range components {
|
|
response[i] = map[string]any{
|
|
"type": c.Type,
|
|
"description": c.Description,
|
|
"stack": c.Stack,
|
|
"default_port": c.DefaultPort,
|
|
"dest_dir": c.DestDir,
|
|
"files": c.Files,
|
|
}
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"components": response,
|
|
})
|
|
}
|