Skip to content

Commit e6ad3e0

Browse files
authored
feat: Implement RSS feed API (#2395)
* 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. * 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 495cc0e commit e6ad3e0

File tree

11 files changed

+969
-43
lines changed

11 files changed

+969
-43
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: 168 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,181 @@
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+
"strconv"
27+
"time"
1928

29+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
2030
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
2131
)
2232

23-
// GetSubscriptionRSS returns a "not supported" error for now.
33+
// RSS struct for marshaling.
34+
type RSS struct {
35+
XMLName xml.Name `xml:"rss"`
36+
Version string `xml:"version,attr"`
37+
AtomNS string `xml:"xmlns:atom,attr"`
38+
Channel Channel `xml:"channel"`
39+
}
40+
41+
type AtomLink struct {
42+
Rel string `xml:"rel,attr"`
43+
Href string `xml:"href,attr"`
44+
}
45+
46+
type Channel struct {
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"`
57+
}
58+
59+
type Item struct {
60+
Description string `xml:"description"`
61+
GUID GUID `xml:"guid"`
62+
PubDate string `xml:"pubDate"`
63+
}
64+
65+
// GetSubscriptionRSS handles the request to get an RSS feed for a subscription.
2466
// nolint: ireturn // Signature generated from OpenAPI.
2567
func (s *Server) GetSubscriptionRSS(
26-
_ context.Context,
27-
_ backend.GetSubscriptionRSSRequestObject,
68+
ctx context.Context,
69+
request backend.GetSubscriptionRSSRequestObject,
2870
) (backend.GetSubscriptionRSSResponseObject, error) {
29-
return backend.GetSubscriptionRSS500JSONResponse{
30-
Code: 500,
31-
Message: "Not supported",
71+
sub, err := s.wptMetricsStorer.GetSavedSearchSubscriptionPublic(ctx, request.SubscriptionId)
72+
if err != nil {
73+
if errors.Is(err, backendtypes.ErrEntityDoesNotExist) {
74+
return backend.GetSubscriptionRSS404JSONResponse{
75+
Code: http.StatusNotFound,
76+
Message: "Subscription not found",
77+
}, nil
78+
}
79+
80+
return backend.GetSubscriptionRSS500JSONResponse{
81+
Code: http.StatusInternalServerError,
82+
Message: "Internal server error",
83+
}, nil
84+
}
85+
86+
search, err := s.wptMetricsStorer.GetSavedSearchPublic(ctx, sub.Subscribable.Id)
87+
if err != nil {
88+
if errors.Is(err, backendtypes.ErrEntityDoesNotExist) {
89+
return backend.GetSubscriptionRSS404JSONResponse{
90+
Code: http.StatusNotFound,
91+
Message: "Saved search not found",
92+
}, nil
93+
}
94+
slog.ErrorContext(ctx, "failed to get saved search", "error", err)
95+
96+
return backend.GetSubscriptionRSS500JSONResponse{
97+
Code: http.StatusInternalServerError,
98+
Message: "Internal server error",
99+
}, nil
100+
}
101+
102+
snapshotType := string(sub.Frequency)
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+
)
111+
if err != nil {
112+
slog.ErrorContext(ctx, "failed to list notification events", "error", err)
113+
114+
return backend.GetSubscriptionRSS500JSONResponse{
115+
Code: http.StatusInternalServerError,
116+
Message: "Internal server error",
117+
}, nil
118+
}
119+
120+
channelLink := s.baseURL.String() + "/features?q=" + url.QueryEscape(search.Query)
121+
122+
rss := RSS{
123+
XMLName: xml.Name{Local: "rss", Space: ""},
124+
Version: "2.0",
125+
AtomNS: "http://www.w3.org/2005/Atom",
126+
Channel: Channel{
127+
Title: fmt.Sprintf("WebStatus.dev - %s", search.Name),
128+
Link: channelLink,
129+
Description: fmt.Sprintf("RSS feed for saved search: %s", search.Name),
130+
Items: make([]Item, 0, len(events)),
131+
AtomLinks: nil,
132+
},
133+
}
134+
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+
165+
for _, e := range events {
166+
rss.Channel.Items = append(rss.Channel.Items, Item{
167+
Description: string(e.Summary),
168+
GUID: GUID{
169+
Value: e.ID,
170+
IsPermaLink: "false",
171+
},
172+
PubDate: e.Timestamp.Format(time.RFC1123Z),
173+
})
174+
}
175+
176+
xmlBytes, err := xml.MarshalIndent(rss, "", " ")
177+
if err != nil {
178+
slog.ErrorContext(ctx, "failed to marshal RSS XML", "error", err)
179+
180+
return backend.GetSubscriptionRSS500JSONResponse{
181+
Code: http.StatusInternalServerError,
182+
Message: "Internal server error",
183+
}, nil
184+
}
185+
186+
var buf bytes.Buffer
187+
buf.Grow(len(xml.Header) + len(xmlBytes))
188+
buf.WriteString(xml.Header)
189+
buf.Write(xmlBytes)
190+
191+
return backend.GetSubscriptionRSS200ApplicationrssXmlResponse{
192+
Body: bytes.NewReader(buf.Bytes()),
193+
ContentLength: int64(buf.Len()),
32194
}, nil
33195
}

0 commit comments

Comments
 (0)