internal/webhooks/models.go
Rama main · réplica de lectura del repositorio original.
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
}