Skip to content

Commit aa850bd

Browse files
committed
Per-package RSS/atom feed (endpoint only)
1 parent 32ade8b commit aa850bd

File tree

8 files changed

+212
-47
lines changed

8 files changed

+212
-47
lines changed

app/lib/frontend/handlers/atom_feed.dart

Lines changed: 119 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,27 @@ import '../../shared/urls.dart' as urls;
1717
import '../../shared/utils.dart';
1818
import '../dom/dom.dart' as d;
1919

20-
/// Handles requests for /feed.atom
21-
Future<shelf.Response> atomFeedHandler(shelf.Request request) async {
20+
/// Handles requests for `/feed.atom`
21+
Future<shelf.Response> allPackagesAtomFeedhandler(shelf.Request request) async {
2222
final feedContent =
23-
await cache.atomFeedXml().get(buildAllPackagesAtomFeedContent);
23+
await cache.allPackagesAtomFeedXml().get(buildAllPackagesAtomFeedContent);
24+
return shelf.Response.ok(
25+
feedContent,
26+
headers: {
27+
'content-type': 'application/atom+xml; charset="utf-8"',
28+
'x-content-type-options': 'nosniff',
29+
},
30+
);
31+
}
32+
33+
/// Handles requests for `/packages/<package>/feed.atom`
34+
Future<shelf.Response> packageAtomFeedhandler(
35+
shelf.Request request,
36+
String package,
37+
) async {
38+
final feedContent = await cache
39+
.packageAtomFeedXml(package)
40+
.get(() => buildPackageAtomFeedContent(package));
2441
return shelf.Response.ok(
2542
feedContent,
2643
headers: {
@@ -33,7 +50,20 @@ Future<shelf.Response> atomFeedHandler(shelf.Request request) async {
3350
/// Builds the content of the /feed.atom endpoint.
3451
Future<String> buildAllPackagesAtomFeedContent() async {
3552
final versions = await packageBackend.latestPackageVersions(limit: 100);
36-
final feed = _feedFromPackageVersions(versions);
53+
final feed = _allPackagesFeed(versions);
54+
return feed.toXmlDocument();
55+
}
56+
57+
/// Builds the content of the `/packages/<package>/feed.atom` endpoint.
58+
Future<String> buildPackageAtomFeedContent(String package) async {
59+
final versions = await packageBackend
60+
.streamVersionsOfPackage(
61+
package,
62+
order: '-created',
63+
limit: 10,
64+
)
65+
.toList();
66+
final feed = _packageFeed(package, versions);
3767
return feed.toXmlDocument();
3868
}
3969

@@ -46,8 +76,15 @@ class FeedEntry {
4676
final String alternateUrl;
4777
final String? alternateTitle;
4878

49-
FeedEntry(this.id, this.title, this.updated, this.publisherId, this.content,
50-
this.alternateUrl, this.alternateTitle);
79+
FeedEntry({
80+
required this.id,
81+
required this.title,
82+
required this.updated,
83+
this.publisherId,
84+
required this.content,
85+
required this.alternateUrl,
86+
required this.alternateTitle,
87+
});
5188

5289
d.Node toNode() {
5390
return d.element(
@@ -75,27 +112,33 @@ class FeedEntry {
75112
class Feed {
76113
final String id;
77114
final String title;
78-
final String subTitle;
115+
final String? subTitle;
79116
final DateTime updated;
80-
final String author;
117+
final String? author;
81118
final String alternateUrl;
82119
final String selfUrl;
83120
final String generator;
84121
final String generatorVersion;
85122

86123
final List<FeedEntry> entries;
87124

88-
Feed(
89-
this.id,
90-
this.title,
91-
this.subTitle,
92-
this.updated,
93-
this.author,
94-
this.alternateUrl,
95-
this.selfUrl,
96-
this.generator,
97-
this.generatorVersion,
98-
this.entries);
125+
Feed({
126+
required this.title,
127+
this.subTitle,
128+
this.author,
129+
required this.alternateUrl,
130+
required this.selfUrl,
131+
this.generator = 'Pub Feed Generator',
132+
this.generatorVersion = '0.1.0',
133+
required this.entries,
134+
}) : id = selfUrl,
135+
// Set the updated timestamp to the latest version timestamp. This prevents
136+
// unnecessary updates in the exported API bucket and makes tests consistent.
137+
updated = entries.isNotEmpty
138+
? entries
139+
.map((v) => v.updated)
140+
.reduce((a, b) => a.isAfter(b) ? a : b)
141+
: clock.now().toUtc();
99142

100143
String toXmlDocument() {
101144
final buffer = StringBuffer();
@@ -112,7 +155,8 @@ class Feed {
112155
d.element('id', text: id),
113156
d.element('title', text: title),
114157
d.element('updated', text: updated.toIso8601String()),
115-
d.element('author', child: d.element('name', text: author)),
158+
if (author != null)
159+
d.element('author', child: d.element('name', text: author)),
116160
d.element(
117161
'link',
118162
attributes: {'href': alternateUrl, 'rel': 'alternate'},
@@ -123,14 +167,14 @@ class Feed {
123167
attributes: {'version': generatorVersion},
124168
text: generator,
125169
),
126-
d.element('subtitle', text: subTitle),
170+
if (subTitle != null) d.element('subtitle', text: subTitle),
127171
...entries.map((e) => e.toNode()),
128172
],
129173
);
130174
}
131175
}
132176

133-
Feed _feedFromPackageVersions(List<PackageVersion> versions) {
177+
Feed _allPackagesFeed(List<PackageVersion> versions) {
134178
final entries = <FeedEntry>[];
135179
for (var i = 0; i < versions.length; i++) {
136180
final version = versions[i];
@@ -145,25 +189,59 @@ Feed _feedFromPackageVersions(List<PackageVersion> versions) {
145189
final id = createUuid(hash.bytes.sublist(0, 16));
146190
final title = 'v${version.version} of ${version.package}';
147191
final content = version.ellipsizedDescription ?? '[no description]';
148-
entries.add(FeedEntry(id, title, version.created!, version.publisherId,
149-
content, alternateUrl, alternateTitle));
192+
entries.add(FeedEntry(
193+
id: id,
194+
title: title,
195+
updated: version.created!,
196+
publisherId: version.publisherId,
197+
content: content,
198+
alternateUrl: alternateUrl,
199+
alternateTitle: alternateTitle,
200+
));
150201
}
151202

152-
final id =
153-
activeConfiguration.primarySiteUri.resolve('/feed.atom').toString();
154-
final selfUrl = id;
155-
156-
final title = 'Pub Packages for Dart';
157-
final subTitle = 'Last Updated Packages';
158-
final alternateUrl =
159-
activeConfiguration.primarySiteUri.resolve('/').toString();
160-
final author = 'Dart Team';
161-
// Set the updated timestamp to the latest version timestamp. This prevents
162-
// unnecessary updates in the exported API bucket and makes tests consistent.
163-
final updated = versions.isNotEmpty
164-
? versions.map((v) => v.created!).reduce((a, b) => a.isAfter(b) ? a : b)
165-
: clock.now().toUtc();
166-
167-
return Feed(id, title, subTitle, updated, author, alternateUrl, selfUrl,
168-
'Pub Feed Generator', '0.1.0', entries);
203+
return Feed(
204+
title: 'Pub Packages for Dart',
205+
subTitle: 'Last Updated Packages',
206+
author: 'Dart Team',
207+
alternateUrl: activeConfiguration.primarySiteUri.resolve('/').toString(),
208+
selfUrl:
209+
activeConfiguration.primarySiteUri.resolve('/feed.atom').toString(),
210+
entries: entries,
211+
);
212+
}
213+
214+
Feed _packageFeed(String package, List<PackageVersion> versions) {
215+
return Feed(
216+
title: 'Most recently published versions for package $package',
217+
alternateUrl: activeConfiguration.primarySiteUri
218+
.resolve(urls.pkgPageUrl(package))
219+
.toString(),
220+
subTitle: versions.firstOrNull?.ellipsizedDescription,
221+
selfUrl: activeConfiguration.primarySiteUri
222+
.resolve(urls.pkgFeedUrl(package))
223+
.toString(),
224+
author: versions.firstOrNull?.publisherId,
225+
entries: versions.map((v) {
226+
final hash =
227+
sha512.convert(utf8.encode('package-feed/$package/${v.version}'));
228+
final id = createUuid(hash.bytes.sublist(0, 16));
229+
final alternateUrl = activeConfiguration.primarySiteUri
230+
.replace(
231+
path: urls.pkgPageUrl(
232+
package,
233+
version: v.version,
234+
))
235+
.toString();
236+
return FeedEntry(
237+
id: id,
238+
title: 'v${v.version} of $package',
239+
alternateUrl: alternateUrl,
240+
alternateTitle: v.version,
241+
content:
242+
'${v.version} was published on ${shortDateFormat.format(v.created!)}.',
243+
updated: v.created!,
244+
);
245+
}).toList(),
246+
);
169247
}

app/lib/frontend/handlers/routes.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ class PubSiteService {
176176
Future<Response> packageVersions(Request request, String package) =>
177177
packageVersionsListHandler(request, package);
178178

179+
/// Renders the Atom XML feed for the package.
180+
@Route.get('/packages/<package>/feed.atom')
181+
Future<Response> packageAtomFeed(Request request, String package) =>
182+
packageAtomFeedhandler(request, package);
183+
179184
@Route.get('/packages/<package>')
180185
Future<Response> package(Request request, String package) =>
181186
packageVersionHandlerHtml(request, package);
@@ -261,7 +266,8 @@ class PubSiteService {
261266

262267
/// Renders the Atom XML feed
263268
@Route.get('/feed.atom')
264-
Future<Response> atomFeed(Request request) => atomFeedHandler(request);
269+
Future<Response> allPackagesAtomFeed(Request request) =>
270+
allPackagesAtomFeedhandler(request);
265271

266272
/// Renders the main help page
267273
@Route.get('/help')

app/lib/frontend/handlers/routes.g.dart

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/lib/package/backend.dart

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,19 @@ class PackageBackend {
295295
}
296296

297297
/// Looks up all versions of a package and return them as a [Stream].
298-
Stream<PackageVersion> streamVersionsOfPackage(String packageName) {
298+
Stream<PackageVersion> streamVersionsOfPackage(
299+
String packageName, {
300+
String? order,
301+
int? limit,
302+
}) {
299303
final packageKey = db.emptyKey.append(Package, id: packageName);
300304
final query = db.query<PackageVersion>(ancestorKey: packageKey);
305+
if (order != null) {
306+
query.order(order);
307+
}
308+
if (limit != null && limit > 0) {
309+
query.limit(limit);
310+
}
301311
return query.run();
302312
}
303313

@@ -1727,13 +1737,15 @@ Future<void> purgePackageCache(String package) async {
17271737
cache.packageDataGz(package).purge(),
17281738
cache.packageLatestVersion(package).purge(),
17291739
cache.packageView(package).purge(),
1740+
cache.packageAtomFeedXml(package).purge(),
17301741
cache.uiPackagePage(package, null).purge(),
17311742
cache.uiPackageChangelog(package, null).purge(),
17321743
cache.uiPackageExample(package, null).purge(),
17331744
cache.uiPackageInstall(package, null).purge(),
17341745
cache.uiPackageScore(package, null).purge(),
17351746
cache.uiPackageVersions(package).purge(),
17361747
cache.uiIndexPage().purge(),
1748+
cache.allPackagesAtomFeedXml().purge(),
17371749
]);
17381750
}
17391751

app/lib/shared/redis_cache.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,11 +324,16 @@ class CachePatterns {
324324
decode: (d) => d as bool,
325325
))[publisherId];
326326

327-
Entry<String> atomFeedXml() => _cache
327+
Entry<String> allPackagesAtomFeedXml() => _cache
328328
.withPrefix('atom-feed-xml/')
329329
.withTTL(Duration(minutes: 3))
330330
.withCodec(utf8)['/'];
331331

332+
Entry<String> packageAtomFeedXml(String package) => _cache
333+
.withPrefix('package-atom-feed-xml/')
334+
.withTTL(Duration(minutes: 10))
335+
.withCodec(utf8)[package];
336+
332337
Entry<List<int>> topicNameCompletionDataJsonGz() => _cache
333338
.withPrefix('topic-name-completion-data-json-gz/')
334339
.withTTL(Duration(hours: 8))['-'];

app/lib/shared/urls.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ String pkgArchiveDownloadUrl(String package, String version, {Uri? baseUri}) {
134134
}
135135
}
136136

137+
String pkgFeedUrl(String package) {
138+
return '/packages/$package/feed.atom';
139+
}
140+
137141
String pkgDocUrl(
138142
String package, {
139143
String? version,

app/test/frontend/handlers/atom_feed_test.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,53 @@ void main() {
8080
);
8181
});
8282
});
83+
84+
testWithProfile('/packages/<package>/feed.atom', fn: () async {
85+
final content = await expectAtomXmlResponse(
86+
await issueGet('/packages/oxygen/feed.atom'));
87+
// check if content is valid XML
88+
final root = XmlDocument.parse(content);
89+
final feed = root.rootElement;
90+
91+
final entries = feed.findElements('entry').toList();
92+
expect(entries.length, 3);
93+
expect(
94+
entries.map((e) => e.findElements('title').first.innerText).toList(), [
95+
'v2.0.0-dev of oxygen',
96+
'v1.2.0 of oxygen',
97+
'v1.0.0 of oxygen',
98+
]);
99+
100+
final oxygenExpr = RegExp('<entry>\n'
101+
' <id>urn:uuid:3f5765a8-8fb3-4b6c-83fe-774a73dce135</id>\n'
102+
' <title>v2.0.0-dev of oxygen</title>\n'
103+
' <updated>(.*)</updated>\n'
104+
' <content>2.0.0-dev was published on (.*)</content>\n'
105+
' <link href="${activeConfiguration.primarySiteUri}/packages/oxygen/versions/2.0.0-dev" rel="alternate" title="2.0.0-dev"/>\n'
106+
'</entry>');
107+
expect(
108+
oxygenExpr
109+
.hasMatch(entries.first.toXmlString(pretty: true, indent: ' ')),
110+
isTrue,
111+
reason: entries.first.toXmlString(),
112+
);
113+
114+
entries.forEach((e) => e.parent!.children.remove(e));
115+
116+
final restExp = RegExp('<feed xmlns="http://www.w3.org/2005/Atom">\n'
117+
' <id>${activeConfiguration.primarySiteUri}/packages/oxygen/feed.atom</id>\n'
118+
' <title>Most recently published versions for package oxygen</title>\n'
119+
' <updated>(.*)</updated>\n'
120+
' <link href="${activeConfiguration.primarySiteUri}/packages/oxygen" rel="alternate"/>\n'
121+
' <link href="${activeConfiguration.primarySiteUri}/packages/oxygen/feed.atom" rel="self"/>\n'
122+
' <generator version="0.1.0">Pub Feed Generator</generator>\n'
123+
' <subtitle>oxygen is awesome</subtitle>\n'
124+
'(\\s*)'
125+
'</feed>');
126+
expect(
127+
restExp.hasMatch(feed.toXmlString(pretty: true, indent: ' ')),
128+
isTrue,
129+
reason: feed.toXmlString(),
130+
);
131+
});
83132
}

0 commit comments

Comments
 (0)