Ctrl K
ComunidadGuías…

internal/webhooks/models.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package webhooks

import (
	"fmt"
	"time"

	svcerrors "github.com/sarathsp06/sparrow/internal/errors"
	"github.com/sarathsp06/sparrow/internal/webhooks/client"
)

// WebhookRegistration represents a webhook registration with HTTP configuration
type WebhookRegistration struct {
	ID          string      `db:"id" json:"id"`
	Consumer    string      `db:"consumer" json:"consumer"`
	Events      StringArray `db:"events" json:"events"`
	URL         string      `db:"url" json:"url"`
	Headers     JSONBMap    `db:"headers" json:"headers"`
	Active      bool        `db:"active" json:"active"`
	Description string      `db:"description" json:"description"`
	Health      string      `db:"health" json:"health"`

	// HTTP Configuration
	HTTPConfig WebhookHTTPConfig `json:"http_config"`

	// Ed25519EncryptedPrivateKey holds the envelope-encrypted Ed25519 private key.
	// Only populated on creation; used to derive the public key for API responses.
	Ed25519EncryptedPrivateKey []byte `json:"-"`

	// SignatureType controls which signing scheme is used: "hmac" (default) or "ed25519".
	SignatureType string `json:"signature_type"`

	// RequiresTransform: every subscription must carry an enabled transform.
	RequiresTransform bool `json:"requires_transform"`

	CreatedAt time.Time `db:"created_at" json:"created_at"`
	UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

// WebhookHTTPConfig contains HTTP-specific configuration for webhook delivery
type WebhookHTTPConfig struct {
	MaxRetries          *int `db:"max_retries" json:"max_retries"`
	RetryBackoffSeconds int  `db:"retry_backoff_seconds" json:"retry_backoff_seconds"`
	// CaptureResponseBody controls the stored response body size limit per delivery attempt.
	// false (default): stores up to 1 KB. true: stores up to 1 MB.
	// The response body is always read regardless of this setting (required for HTTP connection reuse).
	// CaptureResponseBody, FollowRedirects, VerifySSL, and MaxRetries are pointers:
	// nil means "not explicitly set, use default"; non-nil (including a zero/false value) overrides it.
	CaptureResponseBody   *bool    `db:"capture_response_body" json:"capture_response_body"`
	FollowRedirects       *bool    `db:"follow_redirects" json:"follow_redirects"`
	VerifySSL             *bool    `db:"verify_ssl" json:"verify_ssl"`
	RequestTimeoutSeconds int      `db:"request_timeout_seconds" json:"request_timeout_seconds"`
	ExpectedStatusCodes   IntArray `db:"expected_status_codes" json:"expected_status_codes"`
	WebhookSecret         string   `db:"webhook_secret" json:"webhook_secret,omitempty"`
	UserAgent             string   `db:"user_agent" json:"user_agent"`
	ContentType           string   `db:"content_type" json:"content_type"`
	RateLimitRPS          *float64 `db:"rate_limit_rps" json:"rate_limit_rps,omitempty"`
}

// DerefIntOr and DerefBoolOr resolve an optional "explicitly set" config
// pointer to its concrete value, falling back to def when unset. Callers
// should only need the fallback defensively: DefaultWebhookHTTPConfig +
// ApplyConfig always leave these fields non-nil in practice.
func DerefIntOr(p *int, def int) int {
	if p == nil {
		return def
	}
	return *p
}

func DerefBoolOr(p *bool, def bool) bool {
	if p == nil {
		return def
	}
	return *p
}

// Retry defaults and limits. With the schedule in queue.WebhookWorker.NextRetry
// (base * 2^(N-1), capped at 24 hours), the defaults retry for about three days
// (1, 2, 4, ... 1024 minutes, then 24 hours twice), like the multi-day windows
// of hosted webhook senders: a receiver outage over a weekend still recovers on
// its own.
const (
	DefaultMaxRetries          = 13
	DefaultRetryBackoffSeconds = 60
	MaxMaxRetries              = 20
	MaxRetryBackoffSeconds     = 3600
)

// DefaultWebhookHTTPConfig returns default HTTP configuration
func DefaultWebhookHTTPConfig() WebhookHTTPConfig {
	return WebhookHTTPConfig{
		MaxRetries:            new(DefaultMaxRetries),
		RetryBackoffSeconds:   DefaultRetryBackoffSeconds,
		CaptureResponseBody:   new(false), // stores up to 1 KB of response body; set true for up to 1 MB
		FollowRedirects:       new(true),
		VerifySSL:             new(true),
		RequestTimeoutSeconds: 30,
		ExpectedStatusCodes:   IntArray{200, 201, 202, 204},
		UserAgent:             "Sparrow-Webhook/1.0",
		ContentType:           "application/json",
	}
}

// ValidateConfig validates the HTTP configuration
func (config WebhookHTTPConfig) ValidateConfig() error {
	if config.MaxRetries != nil && (*config.MaxRetries < 0 || *config.MaxRetries > MaxMaxRetries) {
		return svcerrors.Errorf(svcerrors.InvalidArgument, "max_retries must be between 0 and %d, got %d", MaxMaxRetries, *config.MaxRetries)
	}

	if config.RetryBackoffSeconds <= 0 || config.RetryBackoffSeconds > MaxRetryBackoffSeconds {
		return svcerrors.Errorf(svcerrors.InvalidArgument, "retry_backoff_seconds must be between 1 and %d, got %d", MaxRetryBackoffSeconds, config.RetryBackoffSeconds)
	}

	if config.RequestTimeoutSeconds <= 0 || config.RequestTimeoutSeconds > maxTimeoutSeconds {
		return svcerrors.Errorf(svcerrors.InvalidArgument, "request_timeout_seconds must be between 1 and %d, got %d", maxTimeoutSeconds, config.RequestTimeoutSeconds)
	}

	if len(config.ExpectedStatusCodes) == 0 {
		return svcerrors.Error(svcerrors.InvalidArgument, "expected_status_codes cannot be empty")
	}

	for _, code := range config.ExpectedStatusCodes {
		if code < 100 || code > 599 {
			return svcerrors.Errorf(svcerrors.InvalidArgument, "invalid HTTP status code: %d", code)
		}
	}

	if config.ContentType == "" {
		return svcerrors.Error(svcerrors.InvalidArgument, "content_type cannot be empty")
	}

	// RateLimitRPS: nil is fine (no limit), but if set, must be positive.
	// Matches the DB constraint: CHECK (rate_limit_rps IS NULL OR rate_limit_rps > 0)
	if config.RateLimitRPS != nil && *config.RateLimitRPS <= 0 {
		return svcerrors.Errorf(svcerrors.InvalidArgument, "rate_limit_rps must be positive, got %f", *config.RateLimitRPS)
	}

	return nil
}

// ApplyConfig applies configuration from another config, only overriding non-zero/non-empty values
func (config *WebhookHTTPConfig) ApplyConfig(other *WebhookHTTPConfig) {
	if other == nil {
		return
	}

	// MaxRetries is a pointer: nil means "not set, keep default"; non-nil overrides,
	// including an explicit 0 (no retries).
	if other.MaxRetries != nil {
		config.MaxRetries = other.MaxRetries
	}
	if other.RetryBackoffSeconds > 0 {
		config.RetryBackoffSeconds = other.RetryBackoffSeconds
	}
	if other.RequestTimeoutSeconds > 0 {
		config.RequestTimeoutSeconds = other.RequestTimeoutSeconds
	}

	// Override non-empty arrays
	if len(other.ExpectedStatusCodes) > 0 {
		config.ExpectedStatusCodes = other.ExpectedStatusCodes
	}

	// Override non-empty strings (empty string means not set, keep default)
	if other.WebhookSecret != "" {
		config.WebhookSecret = other.WebhookSecret
	}
	if other.UserAgent != "" {
		config.UserAgent = other.UserAgent
	}
	if other.ContentType != "" {
		config.ContentType = other.ContentType
	}

	// Booleans are pointers: nil means "not set, keep default"; non-nil overrides,
	// including an explicit false.
	if other.CaptureResponseBody != nil {
		config.CaptureResponseBody = other.CaptureResponseBody
	}
	if other.FollowRedirects != nil {
		config.FollowRedirects = other.FollowRedirects
	}
	if other.VerifySSL != nil {
		config.VerifySSL = other.VerifySSL
	}

	// RateLimitRPS is a pointer: nil means "don't change", non-nil overrides (including to set a limit)
	if other.RateLimitRPS != nil {
		config.RateLimitRPS = other.RateLimitRPS
	}
}

// StringArray is a wrapper for PostgreSQL string arrays
type StringArray []string

// IntArray is a wrapper for PostgreSQL integer arrays
type IntArray []int

// JSONBMap is a wrapper for PostgreSQL JSONB maps
type JSONBMap map[string]any

// WebhookRegistrationRequest represents a request to create/update a webhook registration
type WebhookRegistrationRequest struct {
	ID            string             `json:"id,omitempty"`
	Consumer      string             `json:"consumer" validate:"required"`
	Events        []string           `json:"events" validate:"required,min=1"`
	URL           string             `json:"url" validate:"required,url"`
	Headers       map[string]any     `json:"headers,omitempty"`
	SecretHeaders map[string]string  `json:"secret_headers,omitempty"`
	Active        *bool              `json:"active,omitempty"`
	Description   string             `json:"description,omitempty"`
	HTTPConfig    *WebhookHTTPConfig `json:"http_config,omitempty"`
	RateLimitRPS  *float64           `json:"rate_limit_rps,omitempty"`
	SignatureType string             `json:"signature_type,omitempty"` // "hmac" (default) or "ed25519"
	// RequiresTransform marks a receiver that only accepts transformed
	// payloads; TransformTemplate is then required when Events is non-empty.
	RequiresTransform bool `json:"requires_transform,omitempty"`
	// TransformTemplate and TemplateSettings apply to every subscription
	// created from Events. An empty template leaves them untransformed.
	TransformTemplate string                       `json:"transform_template,omitempty"`
	TemplateSettings  SubscriptionTemplateSettings `json:"-"`
	// TemplateMeta labels the template's first saved version on each
	// created subscription (manual or ai_draft, notes, who saved it).
	TemplateMeta TemplateSaveMeta `json:"-"`
}

// ToWebhookRegistration converts the request to a WebhookRegistration
func (req WebhookRegistrationRequest) ToWebhookRegistration() (*WebhookRegistration, error) {
	webhook := &WebhookRegistration{
		ID:            req.ID,
		Consumer:      req.Consumer,
		Events:        StringArray(req.Events),
		URL:           req.URL,
		Headers:       JSONBMap(req.Headers),
		Description:   req.Description,
		Active:        true,
		Health:        "unknown",
		SignatureType: req.SignatureType,
	}

	if req.Active != nil {
		webhook.Active = *req.Active
	}

	// Start with defaults; provided configuration overrides them.
	webhook.HTTPConfig = DefaultWebhookHTTPConfig()
	if req.HTTPConfig != nil {
		webhook.HTTPConfig.ApplyConfig(req.HTTPConfig)
	}
	// The top-level rate_limit_rps is accepted as a shorthand;
	// http_config.rate_limit_rps wins when both are set.
	if webhook.HTTPConfig.RateLimitRPS == nil {
		webhook.HTTPConfig.RateLimitRPS = req.RateLimitRPS
	}
	if err := webhook.HTTPConfig.ValidateConfig(); err != nil {
		return nil, fmt.Errorf("invalid HTTP config: %w", err)
	}

	return webhook, nil
}

// maxTimeoutSeconds bounds request_timeout_seconds and a subscription's
// timeout override; the delivery job's River timeout is sized from it.
const maxTimeoutSeconds = int(client.MaxRequestTimeout / time.Second)

// validateSubscriptionTimeout checks a subscription's timeout override in
// seconds; 0 means the webhook's request_timeout_seconds applies.
func validateSubscriptionTimeout(timeout int) error {
	if timeout < 0 || timeout > maxTimeoutSeconds {
		return svcerrors.Errorf(svcerrors.InvalidArgument, "timeout must be between 0 and %d seconds, got %d", maxTimeoutSeconds, timeout)
	}
	return nil
}