// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "encoding/json" "errors" "log/slog" "net/http" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/pkg/api" ) // CreateAndBuildHandler handles the combined create-project-and-build endpoint. type CreateAndBuildHandler struct { infraService *service.ProjectInfraService buildService *service.BuildService logger *slog.Logger } // NewCreateAndBuildHandler creates a new create-and-build handler. func NewCreateAndBuildHandler( infraService *service.ProjectInfraService, buildService *service.BuildService, logger *slog.Logger, ) *CreateAndBuildHandler { if logger == nil { logger = slog.Default() } return &CreateAndBuildHandler{ infraService: infraService, buildService: buildService, logger: logger, } } // Mount registers the create-and-build route. func (h *CreateAndBuildHandler) Mount(r api.Router) { r.Post("/project/create-and-build", h.CreateAndBuild) } // CreateAndBuildRequest is the request body for POST /project/create-and-build. type CreateAndBuildRequest struct { // Project creation fields Name string `json:"name"` Description string `json:"description,omitempty"` Private bool `json:"private,omitempty"` Template string `json:"template,omitempty"` // Build fields Prompt string `json:"prompt"` Variables map[string]string `json:"variables,omitempty"` AutoCommit bool `json:"auto_commit"` AutoPush bool `json:"auto_push"` CallbackURL string `json:"callback_url,omitempty"` } // CreateAndBuildResponse is the response for POST /project/create-and-build. type CreateAndBuildResponse struct { // Project info ProjectID string `json:"project_id"` Name string `json:"name"` Domain string `json:"domain"` URL string `json:"url"` // Git info Git map[string]string `json:"git,omitempty"` // Build info TaskID string `json:"task_id"` Status string `json:"status"` StatusURL string `json:"status_url"` } // CreateAndBuild creates a project and immediately enqueues a build task. // POST /project/create-and-build func (h *CreateAndBuildHandler) CreateAndBuild(w http.ResponseWriter, r *http.Request) { 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 } if h.buildService == nil { api.WriteInternalError(w, r, "build service not configured") return } r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req CreateAndBuildRequest 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 } if req.Prompt == "" { api.WriteBadRequest(w, r, "prompt is required") return } // Validate callback URL to prevent SSRF if req.CallbackURL != "" { if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil { api.WriteBadRequest(w, r, err.Error()) return } } // Step 1: Create the project projectResult, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{ Name: req.Name, Description: req.Description, Private: req.Private, Template: req.Template, }) if err != nil { if errors.Is(err, domain.ErrInvalidProjectName) { api.WriteBadRequest(w, r, err.Error()) return } h.logger.Error("project creation failed", "error", err, "name", req.Name) api.WriteInternalError(w, r, "failed to create project") return } // Step 2: Enqueue the build task spec := domain.BuildSpec{ Prompt: req.Prompt, Template: req.Template, Variables: req.Variables, AutoCommit: req.AutoCommit, AutoPush: req.AutoPush, CallbackURL: req.CallbackURL, } taskID, err := h.buildService.StartBuild(ctx, projectResult.ProjectID, spec) if err != nil { h.logger.Error("build enqueue failed after project creation", "error", err, "project", projectResult.ProjectID, ) // Project was created but build failed to enqueue. // Return the project info with a generic error and retry URL. api.WriteJSON(w, r, http.StatusCreated, map[string]any{ "project_id": projectResult.ProjectID, "name": projectResult.Name, "domain": projectResult.Domain, "url": projectResult.URL, "build_error": "project created but build failed to enqueue", "retry_url": "/projects/" + projectResult.ProjectID + "/builds", }) return } resp := CreateAndBuildResponse{ ProjectID: projectResult.ProjectID, Name: projectResult.Name, Domain: projectResult.Domain, URL: projectResult.URL, TaskID: taskID, Status: "pending", StatusURL: "/builds/" + taskID, } if projectResult.CloneHTTP != "" { resp.Git = map[string]string{ "owner": projectResult.GitRepoOwner, "name": projectResult.GitRepoName, "clone_ssh": projectResult.CloneSSH, "clone_http": projectResult.CloneHTTP, "html_url": projectResult.HTMLURL, } } api.WriteCreated(w, r, resp) }