rdev/internal/handlers/create_and_build.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

184 lines
5.2 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
"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(), TimeoutHeavyWrite)
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 := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.Name, "name")
v.Required(req.Prompt, "prompt")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
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,
GitCloneURL: projectResult.CloneHTTP, // Required for git ops on shared worker pods
}
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)
}