webhook.go 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. package webhook
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "encoding/hex"
  8. "encoding/json"
  9. "fmt"
  10. "net/http"
  11. "time"
  12. )
  13. // Client handles webhook operations
  14. type Client struct {
  15. config Config
  16. httpClient *http.Client
  17. }
  18. // Payload represents the webhook payload
  19. type Payload struct {
  20. Email string `json:"email"`
  21. Domain string `json:"domain"`
  22. Action string `json:"action"`
  23. Timestamp time.Time `json:"timestamp"`
  24. }
  25. // New creates a new webhook client with the provided configuration
  26. func New(cfg Config) (*Client, error) {
  27. if cfg.BaseURL == "" || cfg.Domain == "" || cfg.SecretKey == "" {
  28. return nil, ErrInvalidConfig
  29. }
  30. // Apply defaults if not set
  31. if cfg.Timeout == 0 {
  32. cfg.Timeout = DefaultConfig().Timeout
  33. }
  34. if cfg.MaxRetries == 0 {
  35. cfg.MaxRetries = DefaultConfig().MaxRetries
  36. }
  37. if cfg.RetryBackoff == 0 {
  38. cfg.RetryBackoff = DefaultConfig().RetryBackoff
  39. }
  40. return &Client{
  41. config: cfg,
  42. httpClient: &http.Client{
  43. Timeout: cfg.Timeout,
  44. },
  45. }, nil
  46. }
  47. // Send sends a webhook with the provided email and action
  48. func (c *Client) Send(ctx context.Context, email, action string) error {
  49. payload := Payload{
  50. Email: email,
  51. Domain: c.config.Domain,
  52. Action: action,
  53. Timestamp: time.Now(),
  54. }
  55. payloadBytes, err := json.Marshal(payload)
  56. if err != nil {
  57. return NewError("marshal", ErrPayloadMarshall, err.Error())
  58. }
  59. // Calculate signature
  60. signature := c.calculateHMAC(payloadBytes)
  61. // Create request
  62. req, err := http.NewRequestWithContext(
  63. ctx,
  64. http.MethodPost,
  65. fmt.Sprintf("%s/webhook/user-sync", c.config.BaseURL),
  66. bytes.NewBuffer(payloadBytes),
  67. )
  68. if err != nil {
  69. return NewError("create_request", ErrRequestCreation, err.Error())
  70. }
  71. // Set headers
  72. req.Header.Set("Content-Type", "application/json")
  73. req.Header.Set("X-Webhook-Signature", signature)
  74. req.Header.Set("X-Webhook-Domain", c.config.Domain)
  75. // Send request with retries
  76. var lastErr error
  77. for retry := 0; retry < c.config.MaxRetries; retry++ {
  78. select {
  79. case <-ctx.Done():
  80. return ctx.Err()
  81. default:
  82. resp, err := c.httpClient.Do(req)
  83. if err != nil {
  84. lastErr = err
  85. time.Sleep(c.config.RetryBackoff * time.Duration(retry+1))
  86. continue
  87. }
  88. defer resp.Body.Close()
  89. if resp.StatusCode == http.StatusOK {
  90. return nil
  91. }
  92. if resp.StatusCode == http.StatusTooManyRequests {
  93. lastErr = ErrTooManyRequests
  94. time.Sleep(c.config.RetryBackoff * time.Duration(retry+1))
  95. continue
  96. }
  97. // Permanent failure
  98. if resp.StatusCode >= 400 && resp.StatusCode != http.StatusTooManyRequests {
  99. return NewHTTPError(
  100. "send",
  101. resp.StatusCode,
  102. ErrRequestFailed,
  103. "permanent failure",
  104. )
  105. }
  106. }
  107. }
  108. if lastErr != nil {
  109. return NewError("send", ErrMaxRetriesReached, lastErr.Error())
  110. }
  111. return NewError("send", ErrMaxRetriesReached, "all retry attempts failed")
  112. }
  113. // calculateHMAC calculates the HMAC signature for the payload
  114. func (c *Client) calculateHMAC(message []byte) string {
  115. mac := hmac.New(sha256.New, []byte(c.config.SecretKey))
  116. mac.Write(message)
  117. return hex.EncodeToString(mac.Sum(nil))
  118. }
  119. // Close closes the webhook client and its resources
  120. func (c *Client) Close() error {
  121. if c.httpClient != nil {
  122. if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
  123. transport.CloseIdleConnections()
  124. }
  125. c.httpClient = nil
  126. }
  127. return nil
  128. }
  129. // ValidateSignature validates the HMAC signature of a webhook payload
  130. func ValidateSignature(payload []byte, signature, secretKey string) bool {
  131. mac := hmac.New(sha256.New, []byte(secretKey))
  132. mac.Write(payload)
  133. expectedMAC := hex.EncodeToString(mac.Sum(nil))
  134. return hmac.Equal([]byte(signature), []byte(expectedMAC))
  135. }