Skip to content

Commit d9e3c0d

Browse files
authored
feat: implement webhook sender worker and update push delivery dispatcher (#2306)
- Created the core webhook sender logic, including URL validation (restricted to https://hooks.slack.com/) and Markdown payload generation for Slack. - Added tests validating realistic search query formats and proper URL encoding. - Updated the push delivery dispatcher fan-out logic to publish to the webhook queue when matching triggers are found.
1 parent 899476b commit d9e3c0d

13 files changed

Lines changed: 2603 additions & 62 deletions

File tree

backend/pkg/httpserver/create_notification_channel.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
2424
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
2525
"github.com/GoogleChrome/webstatus.dev/lib/httpmiddlewares"
26+
"github.com/GoogleChrome/webstatus.dev/lib/httputils"
2627
)
2728

2829
func validateNotificationChannel(input *backend.CreateNotificationChannelRequest) *fieldValidationErrors {
@@ -33,7 +34,7 @@ func validateNotificationChannel(input *backend.CreateNotificationChannelRequest
3334
}
3435

3536
if cfg, err := input.Config.AsWebhookConfig(); err == nil && cfg.Type == backend.WebhookConfigTypeWebhook {
36-
if err := validateSlackWebhookURL(cfg.Url); err != nil {
37+
if err := httputils.ValidateSlackWebhookURL(cfg.Url); err != nil {
3738
fieldErrors.addFieldError("config.url", err)
3839
}
3940
} else {

backend/pkg/httpserver/notification_validation.go

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@
1515
package httpserver
1616

1717
import (
18-
"errors"
1918
"fmt"
20-
"net/url"
21-
"strings"
2219
)
2320

2421
const (
@@ -27,26 +24,6 @@ const (
2724
)
2825

2926
var (
30-
errInvalidSlackWebhookURL = errors.New(
31-
"invalid Slack webhook URL. Must be a valid https://hooks.slack.com/services/ URL")
3227
errNotificationChannelInvalidNameLength = fmt.Errorf("name must be between %d and %d characters long",
3328
notificationChannelNameMinLength, notificationChannelNameMaxLength)
3429
)
35-
36-
// Validates the URL matches the expected Slack webhook URL format as defined by
37-
// https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/
38-
// Ex. "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
39-
func validateSlackWebhookURL(webhookURL string) error {
40-
u, err := url.Parse(webhookURL)
41-
if err != nil {
42-
return err
43-
}
44-
45-
if u.Scheme != "https" ||
46-
u.Host != "hooks.slack.com" ||
47-
!strings.HasPrefix(u.Path, "/services/") {
48-
return errInvalidSlackWebhookURL
49-
}
50-
51-
return nil
52-
}

backend/pkg/httpserver/update_notification_channel.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
2424
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
2525
"github.com/GoogleChrome/webstatus.dev/lib/httpmiddlewares"
26+
"github.com/GoogleChrome/webstatus.dev/lib/httputils"
2627
)
2728

2829
func validateUpdateNotificationChannel(request *backend.UpdateNotificationChannelRequest) *fieldValidationErrors {
@@ -44,7 +45,7 @@ func validateUpdateNotificationChannel(request *backend.UpdateNotificationChanne
4445
}
4546

4647
if cfg, err := request.Config.AsWebhookConfig(); err == nil && cfg.Type == backend.WebhookConfigTypeWebhook {
47-
if err := validateSlackWebhookURL(cfg.Url); err != nil {
48+
if err := httputils.ValidateSlackWebhookURL(cfg.Url); err != nil {
4849
fieldErrors.addFieldError("config.url", err)
4950
}
5051
} else {

lib/httputils/slack_webhook.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httputils
16+
17+
import (
18+
"errors"
19+
"net/url"
20+
"strings"
21+
)
22+
23+
var (
24+
// ErrInvalidSlackWebhookURL is returned when the Slack webhook URL is invalid.
25+
ErrInvalidSlackWebhookURL = errors.New(
26+
"invalid Slack webhook URL. Must be a valid https://hooks.slack.com/services/ URL")
27+
)
28+
29+
// ValidateSlackWebhookURL validates the URL matches the expected Slack webhook URL format as defined by
30+
// https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/
31+
// Ex. "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
32+
func ValidateSlackWebhookURL(webhookURL string) error {
33+
u, err := url.Parse(webhookURL)
34+
if err != nil {
35+
return err
36+
}
37+
38+
if u.Scheme != "https" ||
39+
u.Host != "hooks.slack.com" ||
40+
!strings.HasPrefix(u.Path, "/services/") {
41+
return ErrInvalidSlackWebhookURL
42+
}
43+
44+
return nil
45+
}

backend/pkg/httpserver/notification_validation_test.go renamed to lib/httputils/slack_webhook_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
package httpserver
15+
package httputils
1616

1717
import (
1818
"errors"
@@ -34,17 +34,17 @@ func TestValidateSlackWebhookURL(t *testing.T) {
3434
{
3535
name: "Invalid Scheme (http)",
3636
url: "http://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
37-
expectedError: errInvalidSlackWebhookURL,
37+
expectedError: ErrInvalidSlackWebhookURL,
3838
},
3939
{
4040
name: "Invalid Host",
4141
url: "https://slack.hooks.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
42-
expectedError: errInvalidSlackWebhookURL,
42+
expectedError: ErrInvalidSlackWebhookURL,
4343
},
4444
{
4545
name: "Invalid Path (missing /services/)",
4646
url: "https://hooks.slack.com/foo/bar",
47-
expectedError: errInvalidSlackWebhookURL,
47+
expectedError: ErrInvalidSlackWebhookURL,
4848
},
4949
{
5050
name: "Invalid URL format",
@@ -55,7 +55,7 @@ func TestValidateSlackWebhookURL(t *testing.T) {
5555

5656
for _, tc := range testCases {
5757
t.Run(tc.name, func(t *testing.T) {
58-
err := validateSlackWebhookURL(tc.url)
58+
err := ValidateSlackWebhookURL(tc.url)
5959

6060
if tc.name == "Invalid URL format" {
6161
if err == nil {

workers/push_delivery/cmd/job/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ func main() {
6565
slog.ErrorContext(ctx, "EMAIL_TOPIC_ID is not set. exiting...")
6666
os.Exit(1)
6767
}
68+
// Push destination 2: Webhook
6869
webhookTopicID := os.Getenv("WEBHOOK_TOPIC_ID")
6970
if webhookTopicID == "" {
7071
slog.ErrorContext(ctx, "WEBHOOK_TOPIC_ID is not set. exiting...")

workers/push_delivery/pkg/dispatcher/dispatcher.go

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type SubscriptionFinder interface {
3131

3232
type DeliveryPublisher interface {
3333
PublishEmailJob(ctx context.Context, job workertypes.EmailDeliveryJob) error
34+
PublishWebhookJob(ctx context.Context, job workertypes.WebhookDeliveryJob) error
3435
}
3536

3637
// SummaryParser abstracts the logic for parsing the event summary blob.
@@ -56,17 +57,19 @@ func (d *Dispatcher) ProcessEvent(ctx context.Context,
5657
metadata workertypes.DispatchEventMetadata, summary []byte) error {
5758
slog.InfoContext(ctx, "processing event", "event_id", metadata.EventID, "search_id", metadata.SearchID)
5859

59-
// 1. Generate Delivery Jobs from Event Summary
60+
// 1. Generate Delivery Jobs from Event Summary.
6061
gen := &deliveryJobGenerator{
6162
finder: d.finder,
6263
metadata: metadata,
6364
// We pass the raw summary bytes down so it can be attached to the jobs
6465
// without needing to re-marshal the struct.
65-
rawSummary: summary,
66-
emailJobs: nil,
66+
rawSummary: summary,
67+
emailJobs: nil,
68+
webhookJobs: nil,
6769
}
6870

6971
if err := d.parser(gen.rawSummary, gen); err != nil {
72+
7073
return fmt.Errorf("failed to parse event summary: %w", err)
7174
}
7275

@@ -79,11 +82,11 @@ func (d *Dispatcher) ProcessEvent(ctx context.Context,
7982

8083
slog.InfoContext(ctx, "dispatching jobs", "count", totalJobs)
8184

82-
// 2. Publish Delivery Jobs
85+
// 2. Publish Delivery Jobs.
8386
successCount := 0
8487
failCount := 0
8588

86-
// Publish Email Jobs
89+
// Publish Email Jobs.
8790
for _, job := range gen.emailJobs {
8891
if err := d.publisher.PublishEmailJob(ctx, job); err != nil {
8992
slog.ErrorContext(ctx, "failed to publish email job",
@@ -94,8 +97,16 @@ func (d *Dispatcher) ProcessEvent(ctx context.Context,
9497
}
9598
}
9699

97-
// TODO: Webhook jobs would be published here similarly
98-
// https://github.com/GoogleChrome/webstatus.dev/issues/1859
100+
// Publish Webhook Jobs.
101+
for _, job := range gen.webhookJobs {
102+
if err := d.publisher.PublishWebhookJob(ctx, job); err != nil {
103+
slog.ErrorContext(ctx, "failed to publish webhook job",
104+
"subscription_id", job.SubscriptionID, "error", err)
105+
failCount++
106+
} else {
107+
successCount++
108+
}
109+
}
99110

100111
slog.InfoContext(ctx, "dispatch complete",
101112
"event_id", metadata.EventID,
@@ -112,10 +123,11 @@ func (d *Dispatcher) ProcessEvent(ctx context.Context,
112123

113124
// deliveryJobGenerator implements workertypes.SummaryVisitor to generate jobs from V1 summaries.
114125
type deliveryJobGenerator struct {
115-
finder SubscriptionFinder
116-
metadata workertypes.DispatchEventMetadata
117-
rawSummary []byte
118-
emailJobs []workertypes.EmailDeliveryJob
126+
finder SubscriptionFinder
127+
metadata workertypes.DispatchEventMetadata
128+
rawSummary []byte
129+
emailJobs []workertypes.EmailDeliveryJob
130+
webhookJobs []workertypes.WebhookDeliveryJob
119131
}
120132

121133
func (g *deliveryJobGenerator) VisitV1(s workertypes.EventSummary) error {
@@ -127,6 +139,7 @@ func (g *deliveryJobGenerator) VisitV1(s workertypes.EventSummary) error {
127139
g.metadata.SearchID,
128140
g.metadata.Frequency)
129141
if err != nil {
142+
130143
return fmt.Errorf("failed to find subscribers: %w", err)
131144
}
132145

@@ -144,7 +157,7 @@ func (g *deliveryJobGenerator) VisitV1(s workertypes.EventSummary) error {
144157
}
145158

146159
// 2. Filter & Create Jobs
147-
// Iterate Emails
160+
// Iterate Emails.
148161
for _, sub := range subscribers.Emails {
149162
if !shouldNotifyV1(sub.Triggers, s) {
150163
continue
@@ -159,17 +172,28 @@ func (g *deliveryJobGenerator) VisitV1(s workertypes.EventSummary) error {
159172
})
160173
}
161174

162-
// TODO: Iterate Webhooks when supported.
163-
// https://github.com/GoogleChrome/webstatus.dev/issues/1859
175+
// Iterate Webhooks.
176+
for _, sub := range subscribers.Webhooks {
177+
if !shouldNotifyV1(sub.Triggers, s) {
178+
continue
179+
}
180+
g.webhookJobs = append(g.webhookJobs, workertypes.WebhookDeliveryJob{
181+
SubscriptionID: sub.SubscriptionID,
182+
WebhookURL: sub.WebhookURL,
183+
WebhookType: sub.WebhookType,
184+
SummaryRaw: g.rawSummary,
185+
Metadata: deliveryMetadata,
186+
ChannelID: sub.ChannelID,
187+
Triggers: sub.Triggers,
188+
})
189+
}
164190

165191
return nil
166192
}
167193

168194
// JobCount returns the total number of delivery jobs generated.
169195
func (g *deliveryJobGenerator) JobCount() int {
170-
// TODO: When we add Webhook jobs, sum them here too.
171-
172-
return len(g.emailJobs)
196+
return len(g.emailJobs) + len(g.webhookJobs)
173197
}
174198

175199
// shouldNotifyV1 determines if the V1 event summary matches any of the user's triggers.

0 commit comments

Comments
 (0)