Skip to content

Commit 605d155

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.
1 parent 24a17e9 commit 605d155

File tree

10 files changed

+715
-41
lines changed

10 files changed

+715
-41
lines changed

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)