Skip to content

Commit 0d698a0

Browse files
committed
Add pagination support to RSS feed API
1 parent ef248af commit 0d698a0

File tree

8 files changed

+205
-37
lines changed

8 files changed

+205
-37
lines changed

backend/pkg/httpserver/get_saved_search_rss.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"log/slog"
2424
"net/http"
2525
"net/url"
26+
"strconv"
2627
"time"
2728

2829
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
@@ -33,14 +34,21 @@ import (
3334
type RSS struct {
3435
XMLName xml.Name `xml:"rss"`
3536
Version string `xml:"version,attr"`
37+
AtomNS string `xml:"xmlns:atom,attr"`
3638
Channel Channel `xml:"channel"`
3739
}
3840

41+
type AtomLink struct {
42+
Rel string `xml:"rel,attr"`
43+
Href string `xml:"href,attr"`
44+
}
45+
3946
type Channel struct {
40-
Title string `xml:"title"`
41-
Link string `xml:"link"`
42-
Description string `xml:"description"`
43-
Items []Item `xml:"item"`
47+
Title string `xml:"title"`
48+
Link string `xml:"link"`
49+
Description string `xml:"description"`
50+
AtomLinks []AtomLink `xml:"atom:link"`
51+
Items []Item `xml:"item"`
4452
}
4553

4654
type Item struct {
@@ -87,7 +95,8 @@ func (s *Server) GetSubscriptionRSS(
8795
}
8896

8997
snapshotType := string(sub.Frequency)
90-
events, err := s.wptMetricsStorer.ListSavedSearchNotificationEvents(ctx, search.Id, snapshotType, 20)
98+
pageSize := getPageSizeOrDefault(request.Params.PageSize)
99+
events, nextPageToken, err := s.wptMetricsStorer.ListSavedSearchNotificationEvents(ctx, search.Id, snapshotType, pageSize, request.Params.PageToken)
91100
if err != nil {
92101
slog.ErrorContext(ctx, "failed to list notification events", "error", err)
93102

@@ -102,14 +111,29 @@ func (s *Server) GetSubscriptionRSS(
102111
rss := RSS{
103112
XMLName: xml.Name{Local: "rss", Space: ""},
104113
Version: "2.0",
114+
AtomNS: "http://www.w3.org/2005/Atom",
105115
Channel: Channel{
106116
Title: fmt.Sprintf("WebStatus.dev - %s", search.Name),
107117
Link: channelLink,
108118
Description: fmt.Sprintf("RSS feed for saved search: %s", search.Name),
109119
Items: make([]Item, 0, len(events)),
120+
AtomLinks: nil,
110121
},
111122
}
112123

124+
if nextPageToken != nil {
125+
u := s.baseURL.JoinPath("v1", "subscriptions", request.SubscriptionId, "rss")
126+
q := u.Query()
127+
q.Set("page_token", *nextPageToken)
128+
q.Set("page_size", strconv.Itoa(pageSize))
129+
u.RawQuery = q.Encode()
130+
131+
rss.Channel.AtomLinks = append(rss.Channel.AtomLinks, AtomLink{
132+
Rel: "next",
133+
Href: u.String(),
134+
})
135+
}
136+
113137
for _, e := range events {
114138
rss.Channel.Items = append(rss.Channel.Items, Item{
115139
Description: string(e.Summary),

backend/pkg/httpserver/get_saved_search_rss_test.go

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ func TestGetSubscriptionRSS(t *testing.T) {
7373
eventsCfg: &MockListSavedSearchNotificationEventsConfig{
7474
expectedSavedSearchID: "search-id",
7575
expectedSnapshotType: string(backend.SubscriptionFrequencyImmediate),
76-
expectedLimit: 20,
76+
expectedPageSize: 100,
77+
expectedPageToken: nil,
7778
output: []backendtypes.SavedSearchNotificationEvent{
7879
{
7980
ID: "event-1",
@@ -99,6 +100,70 @@ func TestGetSubscriptionRSS(t *testing.T) {
99100
"<link>http://localhost:8080/features?q=query</link>",
100101
},
101102
},
103+
{
104+
name: "success with pagination",
105+
subCfg: &MockGetSavedSearchSubscriptionPublicConfig{
106+
expectedSubscriptionID: "sub-id",
107+
output: &backend.SubscriptionResponse{
108+
Id: "sub-id",
109+
Subscribable: backend.SavedSearchInfo{
110+
Id: "search-id",
111+
Name: "",
112+
},
113+
ChannelId: "",
114+
CreatedAt: time.Time{},
115+
Frequency: backend.SubscriptionFrequencyImmediate,
116+
Triggers: nil,
117+
UpdatedAt: time.Time{},
118+
},
119+
err: nil,
120+
},
121+
searchCfg: &MockGetSavedSearchPublicConfig{
122+
expectedSavedSearchID: "search-id",
123+
output: &backend.SavedSearchResponse{
124+
Id: "search-id",
125+
Name: "test search",
126+
Query: "query",
127+
BookmarkStatus: nil,
128+
CreatedAt: time.Time{},
129+
Description: nil,
130+
Permissions: nil,
131+
UpdatedAt: time.Time{},
132+
},
133+
err: nil,
134+
},
135+
eventsCfg: &MockListSavedSearchNotificationEventsConfig{
136+
expectedSavedSearchID: "search-id",
137+
expectedSnapshotType: string(backend.SubscriptionFrequencyImmediate),
138+
expectedPageSize: 100,
139+
expectedPageToken: nil,
140+
output: []backendtypes.SavedSearchNotificationEvent{
141+
{
142+
ID: "event-1",
143+
SavedSearchID: "search-id",
144+
SnapshotType: string(backend.SubscriptionFrequencyImmediate),
145+
Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
146+
EventType: "IMMEDIATE_DIFF",
147+
Summary: []byte(`"summary"`),
148+
Reasons: nil,
149+
BlobPath: "",
150+
DiffBlobPath: "",
151+
},
152+
},
153+
outputNextPageToken: stringPtr("next-token"),
154+
err: nil,
155+
},
156+
expectedStatusCode: 200,
157+
expectedContentType: "application/rss+xml",
158+
expectedBodyContains: []string{
159+
"<title>WebStatus.dev - test search</title>",
160+
"<description>RSS feed for saved search: test search</description>",
161+
"<guid>event-1</guid>",
162+
"<pubDate>Thu, 01 Jan 2026 12:00:00 +0000</pubDate>",
163+
"<link>http://localhost:8080/features?q=query</link>",
164+
`<atom:link rel="next" href="http://localhost:8080/v1/subscriptions/sub-id/rss?page_size=100&amp;page_token=next-token"></atom:link>`,
165+
},
166+
},
102167
{
103168
name: "subscription not found",
104169
subCfg: &MockGetSavedSearchSubscriptionPublicConfig{
@@ -184,3 +249,7 @@ func TestGetSubscriptionRSS(t *testing.T) {
184249
})
185250
}
186251
}
252+
253+
func stringPtr(s string) *string {
254+
return &s
255+
}

backend/pkg/httpserver/server.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ type WPTMetricsStorer interface {
170170
ctx context.Context,
171171
savedSearchID string,
172172
snapshotType string,
173-
limit int,
174-
) ([]backendtypes.SavedSearchNotificationEvent, error)
173+
pageSize int,
174+
pageToken *string,
175+
) ([]backendtypes.SavedSearchNotificationEvent, *string, error)
175176
UpdateSavedSearchSubscription(
176177
ctx context.Context,
177178
userID, subscriptionID string,

backend/pkg/httpserver/server_test.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,10 @@ type MockGetSavedSearchSubscriptionPublicConfig struct {
205205
type MockListSavedSearchNotificationEventsConfig struct {
206206
expectedSavedSearchID string
207207
expectedSnapshotType string
208-
expectedLimit int
208+
expectedPageSize int
209+
expectedPageToken *string
209210
output []backendtypes.SavedSearchNotificationEvent
211+
outputNextPageToken *string
210212
err error
211213
}
212214

@@ -781,8 +783,9 @@ func (m *MockWPTMetricsStorer) ListSavedSearchNotificationEvents(
781783
_ context.Context,
782784
savedSearchID string,
783785
snapshotType string,
784-
limit int,
785-
) ([]backendtypes.SavedSearchNotificationEvent, error) {
786+
pageSize int,
787+
pageToken *string,
788+
) ([]backendtypes.SavedSearchNotificationEvent, *string, error) {
786789
m.callCountListSavedSearchNotificationEvents++
787790
if m.listSavedSearchNotificationEventsCfg == nil {
788791
m.t.Fatal("listSavedSearchNotificationEventsCfg is nil")
@@ -801,11 +804,18 @@ func (m *MockWPTMetricsStorer) ListSavedSearchNotificationEvents(
801804
snapshotType,
802805
)
803806
}
804-
if m.listSavedSearchNotificationEventsCfg.expectedLimit != limit {
805-
m.t.Fatalf("unexpected limit. want %d, got %d", m.listSavedSearchNotificationEventsCfg.expectedLimit, limit)
807+
if m.listSavedSearchNotificationEventsCfg.expectedPageSize != pageSize {
808+
m.t.Fatalf("unexpected pageSize. want %d, got %d", m.listSavedSearchNotificationEventsCfg.expectedPageSize, pageSize)
809+
}
810+
if m.listSavedSearchNotificationEventsCfg.expectedPageToken != nil && pageToken != nil {
811+
if *m.listSavedSearchNotificationEventsCfg.expectedPageToken != *pageToken {
812+
m.t.Fatalf("unexpected pageToken. want %s, got %s", *m.listSavedSearchNotificationEventsCfg.expectedPageToken, *pageToken)
813+
}
814+
} else if m.listSavedSearchNotificationEventsCfg.expectedPageToken != pageToken {
815+
m.t.Fatalf("unexpected pageToken. want %v, got %v", m.listSavedSearchNotificationEventsCfg.expectedPageToken, pageToken)
806816
}
807817

808-
return m.listSavedSearchNotificationEventsCfg.output, m.listSavedSearchNotificationEventsCfg.err
818+
return m.listSavedSearchNotificationEventsCfg.output, m.listSavedSearchNotificationEventsCfg.outputNextPageToken, m.listSavedSearchNotificationEventsCfg.err
809819
}
810820

811821
func (m *MockWPTMetricsStorer) DeleteUserSavedSearch(

lib/gcpspanner/saved_search_notification_events.go

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ package gcpspanner
1616

1717
import (
1818
"context"
19+
"errors"
20+
"fmt"
1921
"time"
2022

2123
"cloud.google.com/go/spanner"
@@ -173,24 +175,61 @@ func (c *Client) GetLatestSavedSearchNotificationEvent(
173175
return r.readRowByKey(ctx, key)
174176
}
175177

178+
// savedSearchNotificationEventCursor is used for pagination.
179+
type savedSearchNotificationEventCursor struct {
180+
LastTimestamp time.Time `json:"last_timestamp"`
181+
LastID string `json:"last_id"`
182+
}
183+
184+
// decodeSavedSearchNotificationEventCursor decodes a cursor string.
185+
func decodeSavedSearchNotificationEventCursor(cursor string) (*savedSearchNotificationEventCursor, error) {
186+
return decodeCursor[savedSearchNotificationEventCursor](cursor)
187+
}
188+
189+
// encodeSavedSearchNotificationEventCursor encodes a cursor struct.
190+
func encodeSavedSearchNotificationEventCursor(lastTimestamp time.Time, lastID string) string {
191+
return encodeCursor(savedSearchNotificationEventCursor{
192+
LastTimestamp: lastTimestamp,
193+
LastID: lastID,
194+
})
195+
}
196+
176197
func (c *Client) ListSavedSearchNotificationEvents(ctx context.Context,
177-
savedSearchID string, snapshotType string, limit int) ([]SavedSearchNotificationEvent, error) {
198+
savedSearchID string, snapshotType string, pageSize int, pageToken *string) ([]SavedSearchNotificationEvent, *string, error) {
199+
var parsedToken *savedSearchNotificationEventCursor
200+
var err error
201+
if pageToken != nil {
202+
parsedToken, err = decodeSavedSearchNotificationEventCursor(*pageToken)
203+
if err != nil {
204+
return nil, nil, errors.Join(ErrInternalQueryFailure, err)
205+
}
206+
}
207+
208+
params := map[string]any{
209+
"SavedSearchId": savedSearchID,
210+
"SnapshotType": SavedSearchSnapshotType(snapshotType),
211+
"Limit": pageSize + 1,
212+
}
213+
214+
var pageFilter string
215+
if parsedToken != nil {
216+
pageFilter = `AND (Timestamp < @LastTimestamp OR (Timestamp = @LastTimestamp AND EventId < @LastID))`
217+
params["LastTimestamp"] = parsedToken.LastTimestamp
218+
params["LastID"] = parsedToken.LastID
219+
}
220+
178221
stmt := spanner.Statement{
179-
SQL: `SELECT * FROM SavedSearchNotificationEvents
180-
WHERE SavedSearchId = @SavedSearchId AND SnapshotType = @SnapshotType
181-
ORDER BY Timestamp DESC
182-
LIMIT @Limit`,
183-
Params: map[string]any{
184-
"SavedSearchId": savedSearchID,
185-
"SnapshotType": SavedSearchSnapshotType(snapshotType),
186-
"Limit": limit,
187-
},
222+
SQL: fmt.Sprintf(`SELECT * FROM SavedSearchNotificationEvents
223+
WHERE SavedSearchId = @SavedSearchId AND SnapshotType = @SnapshotType %s
224+
ORDER BY Timestamp DESC, EventId DESC
225+
LIMIT @Limit`, pageFilter),
226+
Params: params,
188227
}
189228
iter := c.Single().Query(ctx, stmt)
190229
defer iter.Stop()
191230

192231
var events []SavedSearchNotificationEvent
193-
err := iter.Do(func(row *spanner.Row) error {
232+
err = iter.Do(func(row *spanner.Row) error {
194233
var e SavedSearchNotificationEvent
195234
if err := row.ToStruct(&e); err != nil {
196235
return err
@@ -200,8 +239,16 @@ func (c *Client) ListSavedSearchNotificationEvents(ctx context.Context,
200239
return nil
201240
})
202241
if err != nil {
203-
return nil, err
242+
return nil, nil, err
243+
}
244+
245+
var newCursor *string
246+
if len(events) > pageSize {
247+
lastEvent := events[pageSize-1]
248+
generatedCursor := encodeSavedSearchNotificationEventCursor(lastEvent.Timestamp, lastEvent.ID)
249+
newCursor = &generatedCursor
250+
events = events[:pageSize]
204251
}
205252

206-
return events, nil
253+
return events, newCursor, nil
207254
}

lib/gcpspanner/saved_search_notification_events_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ func TestListSavedSearchNotificationEvents(t *testing.T) {
461461

462462
for _, tc := range testCases {
463463
t.Run(tc.name, func(t *testing.T) {
464-
events, err := spannerClient.ListSavedSearchNotificationEvents(ctx, savedSearchID, snapshotType, tc.limit)
464+
events, nextPageToken, err := spannerClient.ListSavedSearchNotificationEvents(ctx, savedSearchID, snapshotType, tc.limit, nil)
465465
if err != nil {
466466
t.Fatalf("ListSavedSearchNotificationEvents() failed: %v", err)
467467
}
@@ -470,6 +470,13 @@ func TestListSavedSearchNotificationEvents(t *testing.T) {
470470
t.Errorf("expected %d events, got %d", tc.expectedCount, len(events))
471471
}
472472

473+
if tc.limit == 2 && nextPageToken == nil {
474+
t.Errorf("expected nextPageToken, got nil")
475+
}
476+
if tc.limit == 10 && nextPageToken != nil {
477+
t.Errorf("expected no nextPageToken, got %s", *nextPageToken)
478+
}
479+
473480
for i, expectedID := range tc.expectedIDs {
474481
if events[i].ID != expectedID {
475482
t.Errorf("at index %d: expected ID %s, got %s", i, expectedID, events[i].ID)

lib/gcpspanner/spanneradapters/backend.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ type BackendSpannerClient interface {
163163
UpdateNotificationChannel(ctx context.Context, req gcpspanner.UpdateNotificationChannelRequest) error
164164
DeleteNotificationChannel(ctx context.Context, channelID string, userID string) error
165165
ListSavedSearchNotificationEvents(ctx context.Context,
166-
savedSearchID string, snapshotType string, limit int) ([]gcpspanner.SavedSearchNotificationEvent, error)
166+
savedSearchID string, snapshotType string, pageSize int, pageToken *string) ([]gcpspanner.SavedSearchNotificationEvent, *string, error)
167167
}
168168

169169
// Backend converts queries to spanner to usable entities for the backend
@@ -178,10 +178,10 @@ func NewBackend(client BackendSpannerClient) *Backend {
178178
}
179179

180180
func (s *Backend) ListSavedSearchNotificationEvents(ctx context.Context,
181-
savedSearchID string, snapshotType string, limit int) ([]backendtypes.SavedSearchNotificationEvent, error) {
182-
notifEvents, err := s.client.ListSavedSearchNotificationEvents(ctx, savedSearchID, snapshotType, limit)
181+
savedSearchID string, snapshotType string, pageSize int, pageToken *string) ([]backendtypes.SavedSearchNotificationEvent, *string, error) {
182+
notifEvents, nextPageToken, err := s.client.ListSavedSearchNotificationEvents(ctx, savedSearchID, snapshotType, pageSize, pageToken)
183183
if err != nil {
184-
return nil, err
184+
return nil, nil, err
185185
}
186186

187187
events := make([]backendtypes.SavedSearchNotificationEvent, 0, len(notifEvents))
@@ -203,7 +203,7 @@ func (s *Backend) ListSavedSearchNotificationEvents(ctx context.Context,
203203
})
204204
}
205205

206-
return events, nil
206+
return events, nextPageToken, nil
207207
}
208208

209209
func (s *Backend) SyncUserProfileInfo(ctx context.Context, userProfile backendtypes.UserProfile) error {

0 commit comments

Comments
 (0)