Skip to content

Commit d22c661

Browse files
committed
feat: Implement RSS feed API
This is a first go at implementing this API and is *not* the final form of it. A few things that still need to be done: - With this change, JSON is just being inserted into the XML. This will need to be more properly formatted. - Caching of the responses from the RSS API. - Hardcoding the # of events/diffs returned to the API to 20 for now. Open to any number on this. Also in this commit, openapi randomly stops generating the WebhookConfigTypeWebhook constant after the last commit, so usages of that old constant are updated.
1 parent cb79f40 commit d22c661

14 files changed

Lines changed: 727 additions & 57 deletions

backend/pkg/httpserver/create_notification_channel.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ func validateNotificationChannel(input *backend.CreateNotificationChannelRequest
3434
}
3535

3636
if cfg, err := input.Config.AsWebhookConfig(); err == nil && cfg.Type == backend.WebhookConfigType("webhook") {
37-
3837
if err := httputils.ValidateSlackWebhookURL(cfg.Url); err != nil {
3938
fieldErrors.addFieldError("config.url", err)
4039
}

backend/pkg/httpserver/create_notification_channel_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func TestCreateNotificationChannel(t *testing.T) {
5555
Name: "My Webhook",
5656
Config: newTestCreateNotificationChannelConfig(t, backend.WebhookConfig{
5757
Type: backend.WebhookConfigType("webhook"),
58-
Url: "https://hooks.slack.com/services/123",
58+
Url: "https://hooks.slack.com/services/123",
5959
}),
6060
},
6161
output: &backend.NotificationChannelResponse{
@@ -64,7 +64,7 @@ func TestCreateNotificationChannel(t *testing.T) {
6464
Type: backend.NotificationChannelResponseTypeWebhook,
6565
Config: newTestNotificationChannelConfig(t, backend.WebhookConfig{
6666
Type: backend.WebhookConfigType("webhook"),
67-
Url: "https://hooks.slack.com/services/123",
67+
Url: "https://hooks.slack.com/services/123",
6868
}),
6969
Status: backend.NotificationChannelStatusEnabled,
7070
CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
@@ -126,7 +126,7 @@ func TestCreateNotificationChannel(t *testing.T) {
126126
Name: "Another Webhook",
127127
Config: newTestCreateNotificationChannelConfig(t, backend.WebhookConfig{
128128
Type: backend.WebhookConfigType("webhook"),
129-
Url: "https://hooks.slack.com/services/456",
129+
Url: "https://hooks.slack.com/services/456",
130130
}),
131131
},
132132
output: nil,
@@ -155,7 +155,7 @@ func TestCreateNotificationChannel(t *testing.T) {
155155
Name: "Generic Webhook",
156156
Config: newTestCreateNotificationChannelConfig(t, backend.WebhookConfig{
157157
Type: backend.WebhookConfigType("webhook"),
158-
Url: "https://hooks.slack.com/services/789",
158+
Url: "https://hooks.slack.com/services/789",
159159
}),
160160
},
161161
output: nil,

backend/pkg/httpserver/get_saved_search_rss.go

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,123 @@
1515
package httpserver
1616

1717
import (
18+
"bytes"
1819
"context"
20+
"encoding/xml"
21+
"errors"
22+
"fmt"
23+
"log/slog"
24+
"net/http"
25+
"net/url"
26+
"time"
1927

28+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
2029
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
2130
)
2231

23-
// GetSubscriptionRSS returns a "not supported" error for now.
32+
// RSS struct for marshaling.
33+
type RSS struct {
34+
XMLName xml.Name `xml:"rss"`
35+
Version string `xml:"version,attr"`
36+
Channel Channel `xml:"channel"`
37+
}
38+
39+
type Channel struct {
40+
Title string `xml:"title"`
41+
Link string `xml:"link"`
42+
Description string `xml:"description"`
43+
Items []Item `xml:"item"`
44+
}
45+
46+
type Item struct {
47+
Description string `xml:"description"`
48+
GUID string `xml:"guid"`
49+
PubDate string `xml:"pubDate"`
50+
}
51+
52+
// GetSubscriptionRSS handles the request to get an RSS feed for a subscription.
2453
// nolint: ireturn // Signature generated from OpenAPI.
2554
func (s *Server) GetSubscriptionRSS(
26-
_ context.Context,
27-
_ backend.GetSubscriptionRSSRequestObject,
55+
ctx context.Context,
56+
request backend.GetSubscriptionRSSRequestObject,
2857
) (backend.GetSubscriptionRSSResponseObject, error) {
29-
return backend.GetSubscriptionRSS500JSONResponse{
30-
Code: 500,
31-
Message: "Not supported",
58+
sub, err := s.wptMetricsStorer.GetSavedSearchSubscriptionPublic(ctx, request.SubscriptionId)
59+
if err != nil {
60+
if errors.Is(err, backendtypes.ErrEntityDoesNotExist) {
61+
return backend.GetSubscriptionRSS404JSONResponse{
62+
Code: http.StatusNotFound,
63+
Message: "Subscription not found",
64+
}, nil
65+
}
66+
67+
return backend.GetSubscriptionRSS500JSONResponse{
68+
Code: http.StatusInternalServerError,
69+
Message: "Internal server error",
70+
}, nil
71+
}
72+
73+
search, err := s.wptMetricsStorer.GetSavedSearchPublic(ctx, sub.Subscribable.Id)
74+
if err != nil {
75+
if errors.Is(err, backendtypes.ErrEntityDoesNotExist) {
76+
return backend.GetSubscriptionRSS404JSONResponse{
77+
Code: http.StatusNotFound,
78+
Message: "Saved search not found",
79+
}, nil
80+
}
81+
slog.ErrorContext(ctx, "failed to get saved search", "error", err)
82+
83+
return backend.GetSubscriptionRSS500JSONResponse{
84+
Code: http.StatusInternalServerError,
85+
Message: "Internal server error",
86+
}, nil
87+
}
88+
89+
snapshotType := string(sub.Frequency)
90+
events, err := s.wptMetricsStorer.ListSavedSearchNotificationEvents(ctx, search.Id, snapshotType, 20)
91+
if err != nil {
92+
slog.ErrorContext(ctx, "failed to list notification events", "error", err)
93+
94+
return backend.GetSubscriptionRSS500JSONResponse{
95+
Code: http.StatusInternalServerError,
96+
Message: "Internal server error",
97+
}, nil
98+
}
99+
100+
channelLink := s.baseURL.String() + "/features?q=" + url.QueryEscape(search.Query)
101+
102+
rss := RSS{
103+
XMLName: xml.Name{Local: "rss", Space: ""},
104+
Version: "2.0",
105+
Channel: Channel{
106+
Title: fmt.Sprintf("WebStatus.dev - %s", search.Name),
107+
Link: channelLink,
108+
Description: fmt.Sprintf("RSS feed for saved search: %s", search.Name),
109+
Items: make([]Item, 0, len(events)),
110+
},
111+
}
112+
113+
for _, e := range events {
114+
rss.Channel.Items = append(rss.Channel.Items, Item{
115+
Description: string(e.Summary),
116+
GUID: e.ID,
117+
PubDate: e.Timestamp.Format(time.RFC1123Z),
118+
})
119+
}
120+
121+
xmlBytes, err := xml.MarshalIndent(rss, "", " ")
122+
if err != nil {
123+
slog.ErrorContext(ctx, "failed to marshal RSS XML", "error", err)
124+
125+
return backend.GetSubscriptionRSS500JSONResponse{
126+
Code: http.StatusInternalServerError,
127+
Message: "Internal server error",
128+
}, nil
129+
}
130+
131+
fullXML := []byte(xml.Header + string(xmlBytes))
132+
133+
return backend.GetSubscriptionRSS200ApplicationrssXmlResponse{
134+
Body: bytes.NewReader(fullXML),
135+
ContentLength: int64(len(fullXML)),
32136
}, nil
33137
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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 httpserver
16+
17+
import (
18+
"context"
19+
"errors"
20+
"io"
21+
"net/http"
22+
"net/http/httptest"
23+
"strings"
24+
"testing"
25+
"time"
26+
27+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
28+
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
29+
)
30+
31+
func TestGetSubscriptionRSS(t *testing.T) {
32+
testCases := []struct {
33+
name string
34+
subCfg *MockGetSavedSearchSubscriptionPublicConfig
35+
searchCfg *MockGetSavedSearchPublicConfig
36+
eventsCfg *MockListSavedSearchNotificationEventsConfig
37+
expectedStatusCode int
38+
expectedContentType string
39+
expectedBodyContains []string
40+
}{
41+
{
42+
name: "success",
43+
subCfg: &MockGetSavedSearchSubscriptionPublicConfig{
44+
expectedSubscriptionID: "sub-id",
45+
output: &backend.SubscriptionResponse{
46+
Id: "sub-id",
47+
Subscribable: backend.SavedSearchInfo{
48+
Id: "search-id",
49+
Name: "",
50+
},
51+
ChannelId: "",
52+
CreatedAt: time.Time{},
53+
Frequency: backend.SubscriptionFrequencyImmediate,
54+
Triggers: nil,
55+
UpdatedAt: time.Time{},
56+
},
57+
err: nil,
58+
},
59+
searchCfg: &MockGetSavedSearchPublicConfig{
60+
expectedSavedSearchID: "search-id",
61+
output: &backend.SavedSearchResponse{
62+
Id: "search-id",
63+
Name: "test search",
64+
Query: "query",
65+
BookmarkStatus: nil,
66+
CreatedAt: time.Time{},
67+
Description: nil,
68+
Permissions: nil,
69+
UpdatedAt: time.Time{},
70+
},
71+
err: nil,
72+
},
73+
eventsCfg: &MockListSavedSearchNotificationEventsConfig{
74+
expectedSavedSearchID: "search-id",
75+
expectedSnapshotType: string(backend.SubscriptionFrequencyImmediate),
76+
expectedLimit: 20,
77+
output: []backendtypes.SavedSearchNotificationEvent{
78+
{
79+
ID: "event-1",
80+
SavedSearchID: "search-id",
81+
SnapshotType: string(backend.SubscriptionFrequencyImmediate),
82+
Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
83+
EventType: "IMMEDIATE_DIFF",
84+
Summary: []byte(`"summary"`),
85+
Reasons: nil,
86+
BlobPath: "",
87+
DiffBlobPath: "",
88+
},
89+
},
90+
err: nil,
91+
},
92+
expectedStatusCode: 200,
93+
expectedContentType: "application/rss+xml",
94+
expectedBodyContains: []string{
95+
"<title>WebStatus.dev - test search</title>",
96+
"<description>RSS feed for saved search: test search</description>",
97+
"<guid>event-1</guid>",
98+
"<pubDate>Thu, 01 Jan 2026 12:00:00 +0000</pubDate>",
99+
"<link>http://localhost:8080/features?q=query</link>",
100+
},
101+
},
102+
{
103+
name: "subscription not found",
104+
subCfg: &MockGetSavedSearchSubscriptionPublicConfig{
105+
expectedSubscriptionID: "missing-sub",
106+
output: nil,
107+
err: backendtypes.ErrEntityDoesNotExist,
108+
},
109+
searchCfg: nil,
110+
eventsCfg: nil,
111+
expectedStatusCode: 404,
112+
expectedContentType: "",
113+
expectedBodyContains: nil,
114+
},
115+
{
116+
name: "500 error",
117+
subCfg: &MockGetSavedSearchSubscriptionPublicConfig{
118+
expectedSubscriptionID: "sub-id",
119+
output: nil,
120+
err: errors.New("db error"),
121+
},
122+
searchCfg: nil,
123+
eventsCfg: nil,
124+
expectedStatusCode: 500,
125+
expectedContentType: "",
126+
expectedBodyContains: nil,
127+
},
128+
}
129+
130+
for _, tc := range testCases {
131+
t.Run(tc.name, func(t *testing.T) {
132+
var mockStorer MockWPTMetricsStorer
133+
mockStorer.getSavedSearchSubscriptionPublicCfg = tc.subCfg
134+
mockStorer.getSavedSearchPublicCfg = tc.searchCfg
135+
mockStorer.listSavedSearchNotificationEventsCfg = tc.eventsCfg
136+
mockStorer.t = t
137+
138+
myServer := Server{
139+
wptMetricsStorer: &mockStorer,
140+
metadataStorer: nil,
141+
userGitHubClientFactory: nil,
142+
eventPublisher: nil,
143+
operationResponseCaches: nil,
144+
baseURL: getTestBaseURL(t),
145+
}
146+
147+
req := httptest.NewRequestWithContext(
148+
context.Background(),
149+
http.MethodGet,
150+
"/v1/subscriptions/"+tc.subCfg.expectedSubscriptionID+"/rss",
151+
nil,
152+
)
153+
154+
// Fix createOpenAPIServerServer call
155+
srv := createOpenAPIServerServer("", &myServer, nil, noopMiddleware)
156+
157+
w := httptest.NewRecorder()
158+
159+
// Fix router.ServeHTTP to srv.Handler.ServeHTTP
160+
srv.Handler.ServeHTTP(w, req)
161+
162+
resp := w.Result()
163+
defer resp.Body.Close()
164+
165+
if resp.StatusCode != tc.expectedStatusCode {
166+
t.Errorf("expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode)
167+
}
168+
169+
if tc.expectedStatusCode == 200 {
170+
contentType := resp.Header.Get("Content-Type")
171+
if contentType != tc.expectedContentType {
172+
t.Errorf("expected content type %s, got %s", tc.expectedContentType, contentType)
173+
}
174+
175+
bodyBytes, _ := io.ReadAll(resp.Body)
176+
bodyStr := string(bodyBytes)
177+
178+
for _, searchStr := range tc.expectedBodyContains {
179+
if !strings.Contains(bodyStr, searchStr) {
180+
t.Errorf("expected body to contain %q, but it did not.\nBody:\n%s", searchStr, bodyStr)
181+
}
182+
}
183+
}
184+
})
185+
}
186+
}

backend/pkg/httpserver/server.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ type WPTMetricsStorer interface {
129129
pageSize int,
130130
pageToken *string,
131131
) (*backend.UserSavedSearchPage, error)
132+
GetSavedSearchPublic(ctx context.Context, savedSearchID string) (*backend.SavedSearchResponse, error)
132133
UpdateUserSavedSearch(
133134
ctx context.Context,
134135
savedSearchID string,
@@ -162,10 +163,20 @@ type WPTMetricsStorer interface {
162163
DeleteSavedSearchSubscription(ctx context.Context, userID, subscriptionID string) error
163164
GetSavedSearchSubscription(ctx context.Context,
164165
userID, subscriptionID string) (*backend.SubscriptionResponse, error)
166+
GetSavedSearchSubscriptionPublic(ctx context.Context, subscriptionID string) (*backend.SubscriptionResponse, error)
165167
ListSavedSearchSubscriptions(ctx context.Context,
166168
userID string, pageSize int, pageToken *string) (*backend.SubscriptionPage, error)
167-
UpdateSavedSearchSubscription(ctx context.Context, userID, subscriptionID string,
168-
req backend.UpdateSubscriptionRequest) (*backend.SubscriptionResponse, error)
169+
ListSavedSearchNotificationEvents(
170+
ctx context.Context,
171+
savedSearchID string,
172+
snapshotType string,
173+
limit int,
174+
) ([]backendtypes.SavedSearchNotificationEvent, error)
175+
UpdateSavedSearchSubscription(
176+
ctx context.Context,
177+
userID, subscriptionID string,
178+
req backend.UpdateSubscriptionRequest,
179+
) (*backend.SubscriptionResponse, error)
169180
}
170181

171182
type Server struct {

0 commit comments

Comments
 (0)