// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "errors" "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/pkg/api" ) // OperationsHandler handles operation query endpoints. type OperationsHandler struct { repo port.OperationRepository } // NewOperationsHandler creates a new operations handler. func NewOperationsHandler(repo port.OperationRepository) *OperationsHandler { return &OperationsHandler{repo: repo} } // Mount registers the operation routes. func (h *OperationsHandler) Mount(r api.Router) { r.Route("/projects/{id}/operations", func(r chi.Router) { r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.List) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/{operation_id}", h.Get) }) } // OperationSummaryResponse is the response for listing operations. type OperationSummaryResponse struct { ID string `json:"id"` Type string `json:"type"` Status string `json:"status"` StartedAt string `json:"started_at"` DurationMs int64 `json:"duration_ms,omitempty"` Error string `json:"error,omitempty"` StepsSummary string `json:"steps_summary,omitempty"` CommitSHA *string `json:"commit_sha,omitempty"` ExternalRef *string `json:"external_ref,omitempty"` } // OperationStepResponse is the response for a single operation step. type OperationStepResponse struct { Name string `json:"name"` Status string `json:"status"` StartedAt string `json:"started_at"` DurationMs int64 `json:"duration_ms,omitempty"` Output map[string]any `json:"output,omitempty"` Error string `json:"error,omitempty"` ErrorDetail string `json:"error_detail,omitempty"` } // OperationDetailResponse is the full response for a single operation. type OperationDetailResponse struct { ID string `json:"id"` ProjectID string `json:"project_id"` Type string `json:"type"` Status string `json:"status"` RequestID string `json:"request_id,omitempty"` TriggeredBy string `json:"triggered_by,omitempty"` CommitSHA string `json:"commit_sha,omitempty"` ExternalRef string `json:"external_ref,omitempty"` StartedAt string `json:"started_at"` CompletedAt *string `json:"completed_at,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` Input map[string]any `json:"input,omitempty"` Output map[string]any `json:"output,omitempty"` Error string `json:"error,omitempty"` ErrorDetail string `json:"error_detail,omitempty"` Steps []OperationStepResponse `json:"steps,omitempty"` } // List returns operations for a project with optional filters. // GET /projects/{id}/operations // Query parameters: // - status: filter by status (pending, running, completed, failed) // - type: filter by type (project.create, component.add, build, resource.provision) // - commit: filter by commit SHA // - since: filter by start time (RFC3339 or duration like "1h", "24h") // - limit: maximum number of entries (default 50, max 200) func (h *OperationsHandler) List(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") if projectID == "" { api.WriteBadRequest(w, r, "project ID is required") return } filters := domain.DefaultOperationFilters() filters.ProjectID = projectID // Parse status filter if status := r.URL.Query().Get("status"); status != "" { s := domain.OperationStatus(status) if !s.IsValid() { api.WriteBadRequest(w, r, "invalid status: must be pending, running, completed, or failed") return } filters.Status = s } // Parse type filter if opType := r.URL.Query().Get("type"); opType != "" { t := domain.OperationType(opType) if !t.IsValid() { api.WriteBadRequest(w, r, "invalid type: must be project.create, component.add, build, or resource.provision") return } filters.Type = t } // Parse commit filter if commit := r.URL.Query().Get("commit"); commit != "" { filters.CommitSHA = commit } // Parse since filter (RFC3339 or duration) if sinceStr := r.URL.Query().Get("since"); sinceStr != "" { since, err := parseSince(sinceStr) if err != nil { api.WriteBadRequest(w, r, "invalid since: must be RFC3339 or duration (e.g., 1h, 24h)") return } filters.Since = since } // Parse limit if limitStr := r.URL.Query().Get("limit"); limitStr != "" { limit, err := strconv.Atoi(limitStr) if err != nil || limit < 1 { api.WriteBadRequest(w, r, "invalid limit: must be a positive integer") return } filters.Limit = limit } filters.Normalize() ops, err := h.repo.List(r.Context(), filters) if err != nil { api.WriteInternalError(w, r, "failed to list operations") return } resp := make([]OperationSummaryResponse, len(ops)) for i, op := range ops { resp[i] = toOperationSummary(op) } api.WriteSuccess(w, r, map[string]any{ "data": resp, "project_id": projectID, "total": len(resp), }) } // Get returns details for a single operation. // GET /projects/{id}/operations/{operation_id} func (h *OperationsHandler) Get(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") operationID := chi.URLParam(r, "operation_id") if projectID == "" { api.WriteBadRequest(w, r, "project ID is required") return } if operationID == "" { api.WriteBadRequest(w, r, "operation ID is required") return } op, err := h.repo.Get(r.Context(), operationID) if err != nil { if errors.Is(err, domain.ErrOperationNotFound) { api.WriteNotFound(w, r, "operation not found") return } api.WriteInternalError(w, r, "failed to get operation") return } // Verify the operation belongs to the requested project if op.ProjectID != projectID { api.WriteNotFound(w, r, "operation not found") return } api.WriteSuccess(w, r, map[string]any{ "data": toOperationDetail(op), }) } // toOperationSummary converts an Operation to a summary response. func toOperationSummary(op *domain.Operation) OperationSummaryResponse { resp := OperationSummaryResponse{ ID: op.ID, Type: string(op.Type), Status: string(op.Status), StartedAt: op.StartedAt.Format(time.RFC3339), DurationMs: op.DurationMs, Error: op.Error, StepsSummary: op.StepsSummary(), } if op.CommitSHA != "" { resp.CommitSHA = &op.CommitSHA } if op.ExternalRef != "" { resp.ExternalRef = &op.ExternalRef } return resp } // toOperationDetail converts an Operation to a full detail response. func toOperationDetail(op *domain.Operation) OperationDetailResponse { resp := OperationDetailResponse{ ID: op.ID, ProjectID: op.ProjectID, Type: string(op.Type), Status: string(op.Status), RequestID: op.RequestID, TriggeredBy: op.TriggeredBy, CommitSHA: op.CommitSHA, ExternalRef: op.ExternalRef, StartedAt: op.StartedAt.Format(time.RFC3339), DurationMs: op.DurationMs, Input: op.Input, Output: op.Output, Error: op.Error, ErrorDetail: op.ErrorDetail, } if op.CompletedAt != nil { s := op.CompletedAt.Format(time.RFC3339) resp.CompletedAt = &s } if len(op.Steps) > 0 { resp.Steps = make([]OperationStepResponse, len(op.Steps)) for i, step := range op.Steps { resp.Steps[i] = OperationStepResponse{ Name: step.Name, Status: string(step.Status), StartedAt: step.StartedAt.Format(time.RFC3339), DurationMs: step.DurationMs, Output: step.Output, Error: step.Error, ErrorDetail: step.ErrorDetail, } } } return resp } // parseSince parses a "since" parameter as either RFC3339 time or duration. func parseSince(s string) (time.Time, error) { // Try RFC3339 first if t, err := time.Parse(time.RFC3339, s); err == nil { return t, nil } // Try duration (e.g., "1h", "24h", "7d") d, err := time.ParseDuration(s) if err != nil { return time.Time{}, err } return time.Now().Add(-d), nil }