Skip to content

Commit 74df9ec

Browse files
committed
fix(rss): align feed pagination indexing and enrich XML atom metadata
- Adjusts SQL cursor sequencing to sort `EventId ASC`. This seamlessly harmonizes with Spanner's default ASC primary key appending so the query plans can completely pipeline native index traversals correctly on identical log timestamps. - Refines `<guid>` serialization to export `isPermaLink="false"`, guarding UUID structures from triggering automatic link parses across rigorous external RSS aggregators. - Inserts standard self-reference atomic path mappings (`rel="self"`) to fortify document compliance.
1 parent 5aa2ff8 commit 74df9ec

File tree

9 files changed

+312
-60
lines changed

9 files changed

+312
-60
lines changed

Makefile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,14 +263,13 @@ shell-lint:
263263
# Format all tracked and untracked Go files (skips ignored/generated files).
264264
go-format: go-install-tools
265265
git ls-files --cached --others --exclude-standard '*.go' | xargs -r -I {} go tool golines -w --max-len=120 {}
266-
266+
go list -f '{{.Dir}}/...' -m | xargs -t golangci-lint run --fix
267267

268268
lint-fix: node-install go-format
269269
npm run lint-fix -w frontend
270270
terraform fmt -recursive .
271271
npx prettier . --write
272272
npx stylelint "frontend/src/**/*.css" --fix
273-
go list -f '{{.Dir}}/...' -m | xargs -t golangci-lint run --fix
274273

275274
style-lint:
276275
npx stylelint "frontend/src/**/*.css"

backend/pkg/httpserver/get_saved_search_rss.go

Lines changed: 69 additions & 11 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,19 +34,31 @@ 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"`
52+
}
53+
54+
type GUID struct {
55+
Value string `xml:",chardata"`
56+
IsPermaLink string `xml:"isPermaLink,attr"`
4457
}
4558

4659
type Item struct {
4760
Description string `xml:"description"`
48-
GUID string `xml:"guid"`
61+
GUID GUID `xml:"guid"`
4962
PubDate string `xml:"pubDate"`
5063
}
5164

@@ -87,7 +100,14 @@ func (s *Server) GetSubscriptionRSS(
87100
}
88101

89102
snapshotType := string(sub.Frequency)
90-
events, err := s.wptMetricsStorer.ListSavedSearchNotificationEvents(ctx, search.Id, snapshotType, 20)
103+
pageSize := getPageSizeOrDefault(request.Params.PageSize)
104+
events, nextPageToken, err := s.wptMetricsStorer.ListSavedSearchNotificationEvents(
105+
ctx,
106+
search.Id,
107+
snapshotType,
108+
pageSize,
109+
request.Params.PageToken,
110+
)
91111
if err != nil {
92112
slog.ErrorContext(ctx, "failed to list notification events", "error", err)
93113

@@ -102,19 +122,54 @@ func (s *Server) GetSubscriptionRSS(
102122
rss := RSS{
103123
XMLName: xml.Name{Local: "rss", Space: ""},
104124
Version: "2.0",
125+
AtomNS: "http://www.w3.org/2005/Atom",
105126
Channel: Channel{
106127
Title: fmt.Sprintf("WebStatus.dev - %s", search.Name),
107128
Link: channelLink,
108129
Description: fmt.Sprintf("RSS feed for saved search: %s", search.Name),
109130
Items: make([]Item, 0, len(events)),
131+
AtomLinks: nil,
110132
},
111133
}
112134

135+
selfURL := s.baseURL.JoinPath("v1", "subscriptions", request.SubscriptionId, "rss")
136+
selfQuery := selfURL.Query()
137+
if request.Params.PageToken != nil {
138+
selfQuery.Set("page_token", *request.Params.PageToken)
139+
}
140+
if request.Params.PageSize != nil {
141+
selfQuery.Set("page_size", strconv.Itoa(*request.Params.PageSize))
142+
}
143+
if len(selfQuery) > 0 {
144+
selfURL.RawQuery = selfQuery.Encode()
145+
}
146+
147+
rss.Channel.AtomLinks = append(rss.Channel.AtomLinks, AtomLink{
148+
Rel: "self",
149+
Href: selfURL.String(),
150+
})
151+
152+
if nextPageToken != nil {
153+
u := s.baseURL.JoinPath("v1", "subscriptions", request.SubscriptionId, "rss")
154+
q := u.Query()
155+
q.Set("page_token", *nextPageToken)
156+
q.Set("page_size", strconv.Itoa(pageSize))
157+
u.RawQuery = q.Encode()
158+
159+
rss.Channel.AtomLinks = append(rss.Channel.AtomLinks, AtomLink{
160+
Rel: "next",
161+
Href: u.String(),
162+
})
163+
}
164+
113165
for _, e := range events {
114166
rss.Channel.Items = append(rss.Channel.Items, Item{
115167
Description: string(e.Summary),
116-
GUID: e.ID,
117-
PubDate: e.Timestamp.Format(time.RFC1123Z),
168+
GUID: GUID{
169+
Value: e.ID,
170+
IsPermaLink: "false",
171+
},
172+
PubDate: e.Timestamp.Format(time.RFC1123Z),
118173
})
119174
}
120175

@@ -128,10 +183,13 @@ func (s *Server) GetSubscriptionRSS(
128183
}, nil
129184
}
130185

131-
fullXML := []byte(xml.Header + string(xmlBytes))
186+
var buf bytes.Buffer
187+
buf.Grow(len(xml.Header) + len(xmlBytes))
188+
buf.WriteString(xml.Header)
189+
buf.Write(xmlBytes)
132190

133191
return backend.GetSubscriptionRSS200ApplicationrssXmlResponse{
134-
Body: bytes.NewReader(fullXML),
135-
ContentLength: int64(len(fullXML)),
192+
Body: bytes.NewReader(buf.Bytes()),
193+
ContentLength: int64(buf.Len()),
136194
}, nil
137195
}

backend/pkg/httpserver/get_saved_search_rss_test.go

Lines changed: 70 additions & 2 deletions
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",
@@ -87,16 +88,83 @@ func TestGetSubscriptionRSS(t *testing.T) {
8788
DiffBlobPath: "",
8889
},
8990
},
91+
outputNextPageToken: nil,
92+
err: nil,
93+
},
94+
expectedStatusCode: 200,
95+
expectedContentType: "application/rss+xml",
96+
expectedBodyContains: []string{
97+
"<title>WebStatus.dev - test search</title>",
98+
"<description>RSS feed for saved search: test search</description>",
99+
"<guid isPermaLink=\"false\">event-1</guid>",
100+
"<pubDate>Thu, 01 Jan 2026 12:00:00 +0000</pubDate>",
101+
"<link>http://localhost:8080/features?q=query</link>",
102+
},
103+
},
104+
{
105+
name: "success with pagination",
106+
subCfg: &MockGetSavedSearchSubscriptionPublicConfig{
107+
expectedSubscriptionID: "sub-id",
108+
output: &backend.SubscriptionResponse{
109+
Id: "sub-id",
110+
Subscribable: backend.SavedSearchInfo{
111+
Id: "search-id",
112+
Name: "",
113+
},
114+
ChannelId: "",
115+
CreatedAt: time.Time{},
116+
Frequency: backend.SubscriptionFrequencyImmediate,
117+
Triggers: nil,
118+
UpdatedAt: time.Time{},
119+
},
120+
err: nil,
121+
},
122+
searchCfg: &MockGetSavedSearchPublicConfig{
123+
expectedSavedSearchID: "search-id",
124+
output: &backend.SavedSearchResponse{
125+
Id: "search-id",
126+
Name: "test search",
127+
Query: "query",
128+
BookmarkStatus: nil,
129+
CreatedAt: time.Time{},
130+
Description: nil,
131+
Permissions: nil,
132+
UpdatedAt: time.Time{},
133+
},
90134
err: nil,
91135
},
136+
eventsCfg: &MockListSavedSearchNotificationEventsConfig{
137+
expectedSavedSearchID: "search-id",
138+
expectedSnapshotType: string(backend.SubscriptionFrequencyImmediate),
139+
expectedPageSize: 100,
140+
expectedPageToken: nil,
141+
output: []backendtypes.SavedSearchNotificationEvent{
142+
{
143+
ID: "event-1",
144+
SavedSearchID: "search-id",
145+
SnapshotType: string(backend.SubscriptionFrequencyImmediate),
146+
Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
147+
EventType: "IMMEDIATE_DIFF",
148+
Summary: []byte(`"summary"`),
149+
Reasons: nil,
150+
BlobPath: "",
151+
DiffBlobPath: "",
152+
},
153+
},
154+
outputNextPageToken: &[]string{"next-token"}[0],
155+
err: nil,
156+
},
92157
expectedStatusCode: 200,
93158
expectedContentType: "application/rss+xml",
94159
expectedBodyContains: []string{
95160
"<title>WebStatus.dev - test search</title>",
96161
"<description>RSS feed for saved search: test search</description>",
97-
"<guid>event-1</guid>",
162+
"<guid isPermaLink=\"false\">event-1</guid>",
98163
"<pubDate>Thu, 01 Jan 2026 12:00:00 +0000</pubDate>",
99164
"<link>http://localhost:8080/features?q=query</link>",
165+
`<atom:link rel="next" ` +
166+
`href="http://localhost:8080/v1/subscriptions/sub-id/rss?page_size=100&amp;page_token=next-token">` +
167+
`</atom:link>`,
100168
},
101169
},
102170
{

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: 30 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,32 @@ 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(
809+
"unexpected pageSize. want %d, got %d",
810+
m.listSavedSearchNotificationEventsCfg.expectedPageSize,
811+
pageSize,
812+
)
813+
}
814+
if m.listSavedSearchNotificationEventsCfg.expectedPageToken != nil && pageToken != nil {
815+
if *m.listSavedSearchNotificationEventsCfg.expectedPageToken != *pageToken {
816+
m.t.Fatalf(
817+
"unexpected pageToken. want %s, got %s",
818+
*m.listSavedSearchNotificationEventsCfg.expectedPageToken,
819+
*pageToken,
820+
)
821+
}
822+
} else if m.listSavedSearchNotificationEventsCfg.expectedPageToken != pageToken {
823+
m.t.Fatalf(
824+
"unexpected pageToken. want %v, got %v",
825+
m.listSavedSearchNotificationEventsCfg.expectedPageToken,
826+
pageToken,
827+
)
806828
}
807829

808-
return m.listSavedSearchNotificationEventsCfg.output, m.listSavedSearchNotificationEventsCfg.err
830+
return m.listSavedSearchNotificationEventsCfg.output,
831+
m.listSavedSearchNotificationEventsCfg.outputNextPageToken,
832+
m.listSavedSearchNotificationEventsCfg.err
809833
}
810834

811835
func (m *MockWPTMetricsStorer) DeleteUserSavedSearch(

0 commit comments

Comments
 (0)