rdev/internal/handlers/webhooks.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

477 lines
13 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"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) {
r.Post("/", h.Create)
r.Get("/", h.List)
r.Get("/{webhookId}", h.Get)
r.Put("/{webhookId}", h.Update)
r.Delete("/{webhookId}", h.Delete)
r.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 := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Validate URL
if req.URL == "" {
api.WriteBadRequest(w, r, "url is required")
return
}
parsedURL, err := url.Parse(req.URL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
api.WriteBadRequest(w, r, "url must be a valid HTTP or HTTPS URL")
return
}
// Validate events
if len(req.Events) == 0 {
api.WriteBadRequest(w, r, "at least one event type is required")
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 := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Update fields
if req.URL != "" {
parsedURL, err := url.Parse(req.URL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
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,
})
}