// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "errors" "fmt" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/worker" "github.com/orchard9/rdev/pkg/api" ) // VerifyHandler handles visual verification endpoints. type VerifyHandler struct { verifyService *service.VerifyService streams port.StreamPublisher } // NewVerifyHandler creates a new verify handler. func NewVerifyHandler(verifyService *service.VerifyService, streams port.StreamPublisher) *VerifyHandler { return &VerifyHandler{ verifyService: verifyService, streams: streams, } } // Mount registers the verify routes. func (h *VerifyHandler) Mount(r api.Router) { r.Route("/verify", func(r chi.Router) { r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Post("/", h.Submit) r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}", h.Get) r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}/stream", h.Stream) r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Delete("/{taskId}", h.Cancel) }) r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/projects/{id}/verify", h.ListByProject) } // SubmitVerifyRequest is the request body for POST /verify. type SubmitVerifyRequest struct { ProjectID string `json:"project_id"` URL string `json:"url"` Viewports []string `json:"viewports,omitempty"` WaitFor string `json:"wait_for,omitempty"` WaitTimeout int `json:"wait_timeout,omitempty"` FullPage bool `json:"full_page,omitempty"` Video bool `json:"video,omitempty"` CallbackURL string `json:"callback_url,omitempty"` } // SubmitVerifyResponse is the response for POST /verify. type SubmitVerifyResponse struct { TaskID string `json:"task_id"` StatusURL string `json:"status_url"` StreamURL string `json:"stream_url"` } // VerifyTaskResponse is the response for GET /verify/{taskId}. type VerifyTaskResponse struct { ID string `json:"id"` ProjectID string `json:"project_id"` Status string `json:"status"` URL string `json:"url"` Screenshots map[string]string `json:"screenshots,omitempty"` Video string `json:"video,omitempty"` Error string `json:"error,omitempty"` CreatedAt string `json:"created_at"` CompletedAt string `json:"completed_at,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` } // Submit enqueues a visual verification task. // POST /verify func (h *VerifyHandler) Submit(w http.ResponseWriter, r *http.Request) { var req SubmitVerifyRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Validate required fields if req.ProjectID == "" { api.WriteBadRequest(w, r, "project_id is required") return } if req.URL == "" { api.WriteBadRequest(w, r, "url is required") return } // Validate callback URL if provided if req.CallbackURL != "" { if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil { api.WriteBadRequest(w, r, err.Error()) return } } // Build verify spec spec := domain.VerifySpec{ URL: req.URL, Viewports: req.Viewports, WaitFor: req.WaitFor, WaitTimeout: req.WaitTimeout, FullPage: req.FullPage, Video: req.Video, CallbackURL: req.CallbackURL, } ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() taskID, err := h.verifyService.SubmitCapture(ctx, req.ProjectID, spec) if err != nil { if errors.Is(err, domain.ErrVerifyURLRequired) { api.WriteBadRequest(w, r, err.Error()) return } api.WriteInternalError(w, r, "failed to submit verify task") return } api.WriteCreated(w, r, SubmitVerifyResponse{ TaskID: taskID, StatusURL: "/verify/" + taskID, StreamURL: "/verify/" + taskID + "/stream", }) } // Get retrieves a verify task by ID. // GET /verify/{taskId} func (h *VerifyHandler) Get(w http.ResponseWriter, r *http.Request) { taskID := chi.URLParam(r, "taskId") if taskID == "" { api.WriteBadRequest(w, r, "task ID is required") return } ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) defer cancel() task, err := h.verifyService.GetCapture(ctx, taskID) if err != nil { if errors.Is(err, domain.ErrWorkTaskNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID)) return } api.WriteInternalError(w, r, "failed to get verify task") return } api.WriteSuccess(w, r, toVerifyTaskResponse(task)) } // Stream streams verify task events via SSE. // GET /verify/{taskId}/stream func (h *VerifyHandler) Stream(w http.ResponseWriter, r *http.Request) { taskID := chi.URLParam(r, "taskId") if taskID == "" { api.WriteBadRequest(w, r, "task ID is required") return } lastEventID := r.Header.Get("Last-Event-ID") // Verify task exists ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) defer cancel() _, err := h.verifyService.GetCapture(ctx, taskID) if err != nil { if errors.Is(err, domain.ErrWorkTaskNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID)) return } api.WriteInternalError(w, r, "failed to get verify task") return } // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") flusher, ok := w.(http.Flusher) if !ok { api.WriteInternalError(w, r, "SSE not supported") return } // Subscribe to events var events <-chan port.StreamEvent var cleanup func() if lastEventID != "" { events, cleanup = h.streams.SubscribeFromID(taskID, lastEventID) } else { events, cleanup = h.streams.Subscribe(taskID) } defer cleanup() // Send initial connected event writeSSE(w, flusher, "connected", map[string]any{ "task_id": taskID, "reconnecting": lastEventID != "", }) // Stream events until client disconnects or stream closes reqCtx := r.Context() heartbeat := time.NewTicker(30 * time.Second) defer heartbeat.Stop() for { select { case <-reqCtx.Done(): return case event, ok := <-events: if !ok { return } writeSSEWithID(w, flusher, event.ID, event.Type, event.Data) // Check for terminal events if event.Type == worker.VerifyEventCompleted || event.Type == worker.VerifyEventFailed { return } case <-heartbeat.C: writeSSE(w, flusher, "heartbeat", map[string]any{ "timestamp": time.Now().UTC().Format(time.RFC3339), }) } } } // Cancel cancels a pending verify task. // DELETE /verify/{taskId} func (h *VerifyHandler) Cancel(w http.ResponseWriter, r *http.Request) { taskID := chi.URLParam(r, "taskId") if taskID == "" { api.WriteBadRequest(w, r, "task ID is required") return } ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() if err := h.verifyService.CancelCapture(ctx, taskID); err != nil { if errors.Is(err, domain.ErrWorkTaskNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID)) return } api.WriteBadRequest(w, r, err.Error()) return } api.WriteSuccess(w, r, map[string]any{ "task_id": taskID, "status": "cancelled", "message": "verify task cancelled successfully", }) } // ListByProject returns verify tasks for a project. // GET /projects/{id}/verify?limit=50&offset=0 func (h *VerifyHandler) ListByProject(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") if projectID == "" { api.WriteBadRequest(w, r, "project ID is required") return } // Parse pagination options opts := domain.DefaultWorkListOptions() if limitStr := r.URL.Query().Get("limit"); limitStr != "" { limit, err := strconv.Atoi(limitStr) if err != nil { api.WriteBadRequest(w, r, "limit must be a valid integer") return } opts.Limit = limit } if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { offset, err := strconv.Atoi(offsetStr) if err != nil { api.WriteBadRequest(w, r, "offset must be a valid integer") return } opts.Offset = offset } ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() result, err := h.verifyService.ListCaptures(ctx, projectID, opts) if err != nil { api.WriteInternalError(w, r, "failed to list verify tasks") return } dtos := make([]*VerifyTaskResponse, len(result.Tasks)) for i, task := range result.Tasks { dtos[i] = toVerifyTaskResponse(task) } api.WriteSuccess(w, r, map[string]any{ "tasks": dtos, "project_id": projectID, "total": result.Total, "limit": result.Limit, "offset": result.Offset, }) } // toVerifyTaskResponse converts a WorkTask to a VerifyTaskResponse. func toVerifyTaskResponse(task *domain.WorkTask) *VerifyTaskResponse { if task == nil { return nil } resp := &VerifyTaskResponse{ ID: task.ID, ProjectID: task.ProjectID, Status: string(task.Status), CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), Error: task.Error, } // Extract URL from spec if url, ok := task.Spec["url"].(string); ok { resp.URL = url } // Extract results from task.Result artifacts if task.Result != nil && task.Result.Artifacts != nil { resp.Screenshots = make(map[string]string) for key, value := range task.Result.Artifacts { if len(key) > 11 && key[:11] == "screenshot_" { viewport := key[11:] resp.Screenshots[viewport] = value } if key == "video" { resp.Video = value } if key == "duration_ms" { if durationMs, err := strconv.ParseInt(value, 10, 64); err == nil { resp.DurationMs = durationMs } } } } if task.CompletedAt != nil { resp.CompletedAt = task.CompletedAt.Format("2006-01-02T15:04:05Z07:00") } return resp }