// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "crypto/rand" "encoding/hex" "errors" "fmt" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // WebhookHandler handles webhook management endpoints. type WebhookHandler struct { webhooks port.WebhookRepository projects port.ProjectRepository } // NewWebhookHandler creates a new webhook handler. func NewWebhookHandler(webhooks port.WebhookRepository, projects port.ProjectRepository) *WebhookHandler { return &WebhookHandler{ webhooks: webhooks, projects: projects, } } // Mount registers the webhook routes. func (h *WebhookHandler) Mount(r api.Router) { r.Route("/projects/{id}/webhooks", func(r chi.Router) { // Write operations r.With(auth.RequireScope(auth.ScopeWebhookWrite, auth.ScopeAdmin)).Post("/", h.Create) r.With(auth.RequireScope(auth.ScopeWebhookWrite, auth.ScopeAdmin)).Put("/{webhookId}", h.Update) r.With(auth.RequireScope(auth.ScopeWebhookWrite, auth.ScopeAdmin)).Delete("/{webhookId}", h.Delete) // Read operations r.With(auth.RequireScope(auth.ScopeWebhookRead, auth.ScopeAdmin)).Get("/", h.List) r.With(auth.RequireScope(auth.ScopeWebhookRead, auth.ScopeAdmin)).Get("/{webhookId}", h.Get) r.With(auth.RequireScope(auth.ScopeWebhookRead, auth.ScopeAdmin)).Get("/{webhookId}/deliveries", h.GetDeliveries) }) } // CreateWebhookRequest is the request body for POST /projects/{id}/webhooks. type CreateWebhookRequest struct { URL string `json:"url"` Events []string `json:"events"` Secret string `json:"secret,omitempty"` // If empty, one will be generated } // CreateWebhookResponse is the response for POST /projects/{id}/webhooks. type CreateWebhookResponse struct { Webhook *WebhookDTO `json:"webhook"` Secret string `json:"secret"` // Only returned on creation } // WebhookDTO is the data transfer object for webhooks. type WebhookDTO struct { ID string `json:"id"` ProjectID string `json:"project_id"` URL string `json:"url"` Events []string `json:"events"` Enabled bool `json:"enabled"` HasSecret bool `json:"has_secret"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // toDTO converts a domain.Webhook to a WebhookDTO. func toDTO(w *domain.Webhook) *WebhookDTO { events := make([]string, len(w.Events)) for i, e := range w.Events { events[i] = string(e) } return &WebhookDTO{ ID: string(w.ID), ProjectID: w.ProjectID, URL: w.URL, Events: events, Enabled: w.Enabled, HasSecret: w.HasSecret(), CreatedAt: w.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: w.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), } } // Create creates a new webhook. // POST /projects/{id}/webhooks func (h *WebhookHandler) Create(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") // Check project exists exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID)) if err != nil { api.WriteInternalError(w, r, "failed to check project") return } if !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID)) return } // Parse request var req CreateWebhookRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Validate required fields if req.URL == "" { api.WriteBadRequest(w, r, "url is required") return } if len(req.Events) == 0 { api.WriteBadRequest(w, r, "at least one event type is required") return } if err := validate.HTTPURL(req.URL, "url"); err != nil { api.WriteBadRequest(w, r, "url must be a valid HTTP or HTTPS URL") return } events := make([]domain.WebhookEventType, len(req.Events)) for i, e := range req.Events { eventType := domain.WebhookEventType(e) if !eventType.IsValid() { api.WriteBadRequest(w, r, fmt.Sprintf("invalid event type: %s", e)) return } events[i] = eventType } // Generate secret if not provided secret := req.Secret if secret == "" { secretBytes := make([]byte, 32) if _, err := rand.Read(secretBytes); err != nil { api.WriteInternalError(w, r, "failed to generate secret") return } secret = hex.EncodeToString(secretBytes) } // Create webhook webhook := &domain.Webhook{ ID: domain.WebhookID(uuid.New().String()), ProjectID: projectID, URL: req.URL, Secret: secret, Events: events, Enabled: true, } if err := h.webhooks.Create(r.Context(), webhook); err != nil { api.WriteInternalError(w, r, "failed to create webhook") return } api.WriteCreated(w, r, CreateWebhookResponse{ Webhook: toDTO(webhook), Secret: secret, // Only returned on creation }) } // List returns all webhooks for a project. // GET /projects/{id}/webhooks func (h *WebhookHandler) List(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") // Check project exists exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID)) if err != nil { api.WriteInternalError(w, r, "failed to check project") return } if !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID)) return } webhooks, err := h.webhooks.ListByProject(r.Context(), projectID) if err != nil { api.WriteInternalError(w, r, "failed to list webhooks") return } dtos := make([]*WebhookDTO, len(webhooks)) for i, wh := range webhooks { dtos[i] = toDTO(wh) } api.WriteSuccess(w, r, map[string]any{ "webhooks": dtos, "total": len(dtos), }) } // Get returns a specific webhook. // GET /projects/{id}/webhooks/{webhookId} func (h *WebhookHandler) Get(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") webhookID := chi.URLParam(r, "webhookId") // Check project exists exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID)) if err != nil { api.WriteInternalError(w, r, "failed to check project") return } if !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID)) return } webhook, err := h.webhooks.GetByID(r.Context(), domain.WebhookID(webhookID)) if err != nil { if errors.Is(err, domain.ErrWebhookNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } api.WriteInternalError(w, r, "failed to get webhook") return } // Verify webhook belongs to project if webhook.ProjectID != projectID { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } api.WriteSuccess(w, r, toDTO(webhook)) } // UpdateWebhookRequest is the request body for PUT /projects/{id}/webhooks/{webhookId}. type UpdateWebhookRequest struct { URL string `json:"url,omitempty"` Events []string `json:"events,omitempty"` Secret string `json:"secret,omitempty"` Enabled *bool `json:"enabled,omitempty"` } // Update updates a webhook. // PUT /projects/{id}/webhooks/{webhookId} func (h *WebhookHandler) Update(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") webhookID := chi.URLParam(r, "webhookId") // Check project exists exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID)) if err != nil { api.WriteInternalError(w, r, "failed to check project") return } if !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID)) return } // Get existing webhook webhook, err := h.webhooks.GetByID(r.Context(), domain.WebhookID(webhookID)) if err != nil { if errors.Is(err, domain.ErrWebhookNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } api.WriteInternalError(w, r, "failed to get webhook") return } // Verify webhook belongs to project if webhook.ProjectID != projectID { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } // Parse request var req UpdateWebhookRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Update fields if req.URL != "" { if err := validate.HTTPURL(req.URL, "url"); err != nil { api.WriteBadRequest(w, r, "url must be a valid HTTP or HTTPS URL") return } webhook.URL = req.URL } if len(req.Events) > 0 { events := make([]domain.WebhookEventType, len(req.Events)) for i, e := range req.Events { eventType := domain.WebhookEventType(e) if !eventType.IsValid() { api.WriteBadRequest(w, r, fmt.Sprintf("invalid event type: %s", e)) return } events[i] = eventType } webhook.Events = events } if req.Secret != "" { webhook.Secret = req.Secret } if req.Enabled != nil { webhook.Enabled = *req.Enabled } if err := h.webhooks.Update(r.Context(), webhook); err != nil { api.WriteInternalError(w, r, "failed to update webhook") return } api.WriteSuccess(w, r, toDTO(webhook)) } // Delete deletes a webhook. // DELETE /projects/{id}/webhooks/{webhookId} func (h *WebhookHandler) Delete(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") webhookID := chi.URLParam(r, "webhookId") // Check project exists exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID)) if err != nil { api.WriteInternalError(w, r, "failed to check project") return } if !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID)) return } // Verify webhook belongs to project webhook, err := h.webhooks.GetByID(r.Context(), domain.WebhookID(webhookID)) if err != nil { if errors.Is(err, domain.ErrWebhookNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } api.WriteInternalError(w, r, "failed to get webhook") return } if webhook.ProjectID != projectID { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } if err := h.webhooks.Delete(r.Context(), domain.WebhookID(webhookID)); err != nil { if errors.Is(err, domain.ErrWebhookNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } api.WriteInternalError(w, r, "failed to delete webhook") return } api.WriteSuccess(w, r, map[string]any{ "id": webhookID, "deleted": true, }) } // DeliveryDTO is the data transfer object for webhook deliveries. type DeliveryDTO struct { ID string `json:"id"` WebhookID string `json:"webhook_id"` EventType string `json:"event_type"` Payload string `json:"payload"` ResponseStatus int `json:"response_status,omitempty"` ResponseBody string `json:"response_body,omitempty"` DeliveredAt string `json:"delivered_at"` Success bool `json:"success"` RetryCount int `json:"retry_count"` ErrorMessage string `json:"error_message,omitempty"` } // GetDeliveries returns delivery history for a webhook. // GET /projects/{id}/webhooks/{webhookId}/deliveries func (h *WebhookHandler) GetDeliveries(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") webhookID := chi.URLParam(r, "webhookId") // Check project exists exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID)) if err != nil { api.WriteInternalError(w, r, "failed to check project") return } if !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID)) return } // Verify webhook belongs to project webhook, err := h.webhooks.GetByID(r.Context(), domain.WebhookID(webhookID)) if err != nil { if errors.Is(err, domain.ErrWebhookNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } api.WriteInternalError(w, r, "failed to get webhook") return } if webhook.ProjectID != projectID { api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID)) return } // Parse query params filters := domain.DefaultWebhookDeliveryFilters() if eventType := r.URL.Query().Get("event_type"); eventType != "" { et := domain.WebhookEventType(eventType) filters.EventType = &et } if successStr := r.URL.Query().Get("success"); successStr != "" { success := successStr == "true" filters.Success = &success } if limit := r.URL.Query().Get("limit"); limit != "" { if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 1000 { filters.Limit = l } } if offset := r.URL.Query().Get("offset"); offset != "" { if o, err := strconv.Atoi(offset); err == nil && o >= 0 { filters.Offset = o } } deliveries, err := h.webhooks.GetDeliveries(r.Context(), domain.WebhookID(webhookID), filters) if err != nil { api.WriteInternalError(w, r, "failed to get deliveries") return } dtos := make([]*DeliveryDTO, len(deliveries)) for i, d := range deliveries { dtos[i] = &DeliveryDTO{ ID: string(d.ID), WebhookID: string(d.WebhookID), EventType: string(d.EventType), Payload: d.Payload, ResponseStatus: d.ResponseStatus, ResponseBody: d.ResponseBody, DeliveredAt: d.DeliveredAt.Format("2006-01-02T15:04:05Z07:00"), Success: d.Success, RetryCount: d.RetryCount, ErrorMessage: d.ErrorMessage, } } api.WriteSuccess(w, r, map[string]any{ "deliveries": dtos, "total": len(dtos), "limit": filters.Limit, "offset": filters.Offset, }) }