// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "encoding/json" "errors" "log/slog" "net/http" "time" "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 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, } } // 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/{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) { // 90 second timeout to allow for Woodpecker sync retry (up to 45s) // plus Gitea repo creation, DNS, and template seeding ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } var req CreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Name == "" { api.WriteBadRequest(w, r, "name is required") return } result, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{ Name: req.Name, Description: req.Description, Private: req.Private, Template: req.Template, }) if err != nil { // 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 } api.WriteCreated(w, r, 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, }) } // List returns all projects. // GET /project func (h *ProjectManagementHandler) List(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) 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(), 10*time.Second) 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(), 60*time.Second) 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(), 10*time.Second) 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(), 10*time.Second) 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, }) }