Skip to content

Commit c9d987d

Browse files
alexluongclaude
andauthored
feat: support empty webhook header prefix (#575) (#797)
Allow disabling the webhook header prefix by setting it to whitespace (e.g. " "). The provider trims whitespace, so " " effectively sets the prefix to empty string. - Config `toConfig()` converts string→*string: empty=""→nil (default), non-empty→&value - `WithHeaderPrefix(*string)`: nil keeps default, non-nil applies with TrimSpace - Add tests for nil/empty/whitespace/custom prefix in both providers Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6a74f9f commit c9d987d

8 files changed

Lines changed: 201 additions & 18 deletions

File tree

internal/config/config_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,28 @@ destinations:
359359
},
360360
want: "x-env-",
361361
},
362+
{
363+
name: "whitespace disables prefix via yaml",
364+
files: map[string][]byte{
365+
"config.yaml": []byte(`
366+
destinations:
367+
webhook:
368+
header_prefix: " "
369+
`),
370+
},
371+
envVars: map[string]string{
372+
"CONFIG": "config.yaml",
373+
},
374+
want: " ",
375+
},
376+
{
377+
name: "whitespace disables prefix via env",
378+
files: map[string][]byte{},
379+
envVars: map[string]string{
380+
"DESTINATIONS_WEBHOOK_HEADER_PREFIX": " ",
381+
},
382+
want: " ",
383+
},
362384
}
363385

364386
for _, tt := range tests {

internal/config/destinations.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ type DestinationWebhookConfig struct {
4040
// TODO: Implement sensitive value handling - https://github.com/hookdeck/outpost/issues/480
4141
Mode string `yaml:"mode" env:"DESTINATIONS_WEBHOOK_MODE" desc:"Webhook mode: 'default' for customizable webhooks or 'standard' for Standard Webhooks specification compliance. Defaults to 'default'." required:"N"`
4242
ProxyURL string `yaml:"proxy_url" env:"DESTINATIONS_WEBHOOK_PROXY_URL" desc:"Proxy URL for routing webhook requests through a proxy server. Supports HTTP and HTTPS proxies. When configured, all outgoing webhook traffic will be routed through the specified proxy." required:"N"`
43-
HeaderPrefix string `yaml:"header_prefix" env:"DESTINATIONS_WEBHOOK_HEADER_PREFIX" desc:"Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode." required:"N"`
43+
HeaderPrefix string `yaml:"header_prefix" env:"DESTINATIONS_WEBHOOK_HEADER_PREFIX" desc:"Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode. Set to whitespace (e.g. ' ') to disable the prefix entirely." required:"N"`
4444
DisableDefaultEventIDHeader bool `yaml:"disable_default_event_id_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER" desc:"If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. Only applies to 'default' mode." required:"N"`
4545
DisableDefaultSignatureHeader bool `yaml:"disable_default_signature_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER" desc:"If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. Only applies to 'default' mode." required:"N"`
4646
DisableDefaultTimestampHeader bool `yaml:"disable_default_timestamp_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER" desc:"If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. Only applies to 'default' mode." required:"N"`
@@ -58,10 +58,20 @@ func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookCo
5858
if mode == "" {
5959
mode = "default"
6060
}
61+
62+
// Convert HeaderPrefix string to *string for the provider:
63+
// - empty string (zero value, unset) → nil → provider uses its default prefix
64+
// - non-empty string (including whitespace like " ") → &value → provider applies it
65+
// (whitespace is trimmed by the provider, so " " effectively disables the prefix)
66+
var headerPrefix *string
67+
if c.HeaderPrefix != "" {
68+
headerPrefix = &c.HeaderPrefix
69+
}
70+
6171
return &destregistrydefault.DestWebhookConfig{
6272
Mode: mode,
6373
ProxyURL: c.ProxyURL,
64-
HeaderPrefix: c.HeaderPrefix,
74+
HeaderPrefix: headerPrefix,
6575
DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader,
6676
DisableDefaultSignatureHeader: c.DisableDefaultSignatureHeader,
6777
DisableDefaultTimestampHeader: c.DisableDefaultTimestampHeader,

internal/destregistry/providers/default.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
type DestWebhookConfig struct {
1818
Mode string
1919
ProxyURL string
20-
HeaderPrefix string
20+
HeaderPrefix *string
2121
DisableDefaultEventIDHeader bool
2222
DisableDefaultSignatureHeader bool
2323
DisableDefaultTimestampHeader bool

internal/destregistry/providers/destwebhook/destwebhook.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,14 @@ var _ destregistry.Provider = (*WebhookDestination)(nil)
119119
// Option is a functional option for configuring WebhookDestination
120120
type Option func(*WebhookDestination)
121121

122-
// WithHeaderPrefix sets a custom prefix for webhook request headers
123-
func WithHeaderPrefix(prefix string) Option {
122+
// WithHeaderPrefix sets a custom prefix for webhook request headers.
123+
// When prefix is nil, the default prefix is used.
124+
// When prefix is non-nil, its value is used (after trimming whitespace),
125+
// allowing an empty string to disable the prefix entirely.
126+
func WithHeaderPrefix(prefix *string) Option {
124127
return func(w *WebhookDestination) {
125-
if prefix != "" {
126-
w.headerPrefix = prefix
128+
if prefix != nil {
129+
w.headerPrefix = strings.TrimSpace(*prefix)
127130
}
128131
}
129132
}

internal/destregistry/providers/destwebhook/destwebhook_publish_test.go

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,13 +279,12 @@ func (s *WebhookPublishSuite) setupExpiredSecretsSuite() {
279279

280280
// Custom header prefix test configuration
281281
func (s *WebhookPublishSuite) setupCustomHeaderSuite() {
282-
const customPrefix = "x-custom-"
283-
consumer := NewWebhookConsumer(customPrefix)
282+
consumer := NewWebhookConsumer("x-custom-")
284283

285284
provider, err := destwebhook.New(
286285
testutil.Registry.MetadataLoader(),
287286
nil,
288-
destwebhook.WithHeaderPrefix(customPrefix),
287+
destwebhook.WithHeaderPrefix(new("x-custom-")),
289288
)
290289
require.NoError(s.T(), err)
291290

@@ -304,7 +303,7 @@ func (s *WebhookPublishSuite) setupCustomHeaderSuite() {
304303
Dest: &dest,
305304
Consumer: consumer,
306305
Asserter: &WebhookAsserter{
307-
headerPrefix: customPrefix,
306+
headerPrefix: "x-custom-",
308307
expectedSignatures: 1,
309308
secrets: []string{"test-secret"},
310309
},
@@ -431,6 +430,79 @@ func TestWebhookPublisher_DisableDefaultHeaders(t *testing.T) {
431430
}
432431
}
433432

433+
func TestWebhookPublisher_EmptyHeaderPrefix(t *testing.T) {
434+
t.Parallel()
435+
436+
tests := []struct {
437+
name string
438+
prefix *string
439+
want string // expected prefix on headers
440+
}{
441+
{
442+
name: "nil prefix uses default",
443+
prefix: nil,
444+
want: "x-outpost-",
445+
},
446+
{
447+
name: "empty string disables prefix",
448+
prefix: new(""),
449+
want: "",
450+
},
451+
{
452+
name: "whitespace-only disables prefix",
453+
prefix: new(" "),
454+
want: "",
455+
},
456+
{
457+
name: "custom prefix is applied",
458+
prefix: new("x-custom-"),
459+
want: "x-custom-",
460+
},
461+
}
462+
463+
for _, tt := range tests {
464+
t.Run(tt.name, func(t *testing.T) {
465+
t.Parallel()
466+
467+
opts := []destwebhook.Option{}
468+
if tt.prefix != nil {
469+
opts = append(opts, destwebhook.WithHeaderPrefix(tt.prefix))
470+
}
471+
472+
provider, err := destwebhook.New(testutil.Registry.MetadataLoader(), nil, opts...)
473+
require.NoError(t, err)
474+
475+
destination := testutil.DestinationFactory.Any(
476+
testutil.DestinationFactory.WithType("webhook"),
477+
testutil.DestinationFactory.WithConfig(map[string]string{
478+
"url": "http://example.com/webhook",
479+
}),
480+
testutil.DestinationFactory.WithCredentials(map[string]string{
481+
"secret": "test-secret",
482+
}),
483+
)
484+
485+
publisher, err := provider.CreatePublisher(context.Background(), &destination)
486+
require.NoError(t, err)
487+
488+
event := testutil.EventFactory.Any(
489+
testutil.EventFactory.WithID("evt_123"),
490+
testutil.EventFactory.WithTopic("user.created"),
491+
testutil.EventFactory.WithDataMap(map[string]interface{}{"key": "value"}),
492+
)
493+
494+
req, err := publisher.(*destwebhook.WebhookPublisher).Format(context.Background(), &event)
495+
require.NoError(t, err)
496+
497+
// Verify headers use the expected prefix
498+
assert.Equal(t, "evt_123", req.Header.Get(tt.want+"event-id"))
499+
assert.NotEmpty(t, req.Header.Get(tt.want+"timestamp"))
500+
assert.Equal(t, "user.created", req.Header.Get(tt.want+"topic"))
501+
assert.NotEmpty(t, req.Header.Get(tt.want+"signature"))
502+
})
503+
}
504+
}
505+
434506
func TestWebhookPublisher_DeliveryMetadata(t *testing.T) {
435507
t.Parallel()
436508

internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,14 @@ func WithProxyURL(proxyURL string) Option {
104104
}
105105
}
106106

107-
// WithHeaderPrefix sets the prefix for metadata headers (defaults to "webhook-")
108-
func WithHeaderPrefix(prefix string) Option {
107+
// WithHeaderPrefix sets the prefix for metadata headers (defaults to "webhook-").
108+
// When prefix is nil, the default prefix is used.
109+
// When prefix is non-nil, its value is used (after trimming whitespace),
110+
// allowing an empty string to disable the prefix entirely.
111+
func WithHeaderPrefix(prefix *string) Option {
109112
return func(d *StandardWebhookDestination) {
110-
if prefix != "" {
111-
d.headerPrefix = prefix
113+
if prefix != nil {
114+
d.headerPrefix = strings.TrimSpace(*prefix)
112115
}
113116
}
114117
}

internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func TestNew(t *testing.T) {
126126
provider, err := destwebhookstandard.New(
127127
testutil.Registry.MetadataLoader(),
128128
nil,
129-
destwebhookstandard.WithHeaderPrefix("x-custom-"),
129+
destwebhookstandard.WithHeaderPrefix(new("x-custom-")),
130130
)
131131
require.NoError(t, err)
132132
assert.NotNil(t, provider)
@@ -139,7 +139,7 @@ func TestNew(t *testing.T) {
139139
nil,
140140
destwebhookstandard.WithUserAgent("test-agent"),
141141
destwebhookstandard.WithProxyURL("http://proxy.example.com"),
142-
destwebhookstandard.WithHeaderPrefix("x-outpost-"),
142+
destwebhookstandard.WithHeaderPrefix(new("x-outpost-")),
143143
)
144144
require.NoError(t, err)
145145
assert.NotNil(t, provider)

internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ func TestStandardWebhookPublisher_CustomHeaderPrefix(t *testing.T) {
384384
provider, err := destwebhookstandard.New(
385385
testutil.Registry.MetadataLoader(),
386386
nil,
387-
destwebhookstandard.WithHeaderPrefix("x-custom-"),
387+
destwebhookstandard.WithHeaderPrefix(new("x-custom-")),
388388
)
389389
require.NoError(t, err)
390390

@@ -434,6 +434,79 @@ func TestStandardWebhookPublisher_CustomHeaderPrefix(t *testing.T) {
434434
}
435435
}
436436

437+
func TestStandardWebhookPublisher_EmptyHeaderPrefix(t *testing.T) {
438+
t.Parallel()
439+
440+
tests := []struct {
441+
name string
442+
prefix *string
443+
want string // expected prefix on headers
444+
}{
445+
{
446+
name: "nil prefix uses default",
447+
prefix: nil,
448+
want: "webhook-",
449+
},
450+
{
451+
name: "empty string disables prefix",
452+
prefix: new(""),
453+
want: "",
454+
},
455+
{
456+
name: "whitespace-only disables prefix",
457+
prefix: new(" "),
458+
want: "",
459+
},
460+
{
461+
name: "custom prefix is applied",
462+
prefix: new("x-custom-"),
463+
want: "x-custom-",
464+
},
465+
}
466+
467+
for _, tt := range tests {
468+
t.Run(tt.name, func(t *testing.T) {
469+
t.Parallel()
470+
471+
opts := []destwebhookstandard.Option{}
472+
if tt.prefix != nil {
473+
opts = append(opts, destwebhookstandard.WithHeaderPrefix(tt.prefix))
474+
}
475+
476+
provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil, opts...)
477+
require.NoError(t, err)
478+
479+
dest := testutil.DestinationFactory.Any(
480+
testutil.DestinationFactory.WithType("webhook"),
481+
testutil.DestinationFactory.WithConfig(map[string]string{
482+
"url": "http://example.com/webhook",
483+
}),
484+
testutil.DestinationFactory.WithCredentials(map[string]string{
485+
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
486+
}),
487+
)
488+
489+
publisher, err := provider.CreatePublisher(context.Background(), &dest)
490+
require.NoError(t, err)
491+
492+
event := testutil.EventFactory.Any(
493+
testutil.EventFactory.WithID("msg_test123"),
494+
testutil.EventFactory.WithTopic("user.created"),
495+
testutil.EventFactory.WithDataMap(map[string]interface{}{"key": "value"}),
496+
)
497+
498+
req, err := publisher.(*destwebhookstandard.StandardWebhookPublisher).Format(context.Background(), &event)
499+
require.NoError(t, err)
500+
501+
// Verify headers use the expected prefix
502+
assert.Equal(t, "msg_test123", req.Header.Get(tt.want+"id"))
503+
assert.NotEmpty(t, req.Header.Get(tt.want+"timestamp"))
504+
assert.NotEmpty(t, req.Header.Get(tt.want+"signature"))
505+
assert.Equal(t, "user.created", req.Header.Get(tt.want+"topic"))
506+
})
507+
}
508+
}
509+
437510
func TestStandardWebhookPublisher_CustomHeaders(t *testing.T) {
438511
t.Parallel()
439512

0 commit comments

Comments
 (0)