Skip to content

Commit e609316

Browse files
authored
Per-package RSS/atom feed (endpoint only) (#8703)
1 parent 1563b13 commit e609316

File tree

9 files changed

+222
-48
lines changed

9 files changed

+222
-48
lines changed

app/lib/frontend/handlers/atom_feed.dart

Lines changed: 128 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,38 @@ import 'package:clock/clock.dart';
99
import 'package:crypto/crypto.dart';
1010
import 'package:shelf/shelf.dart' as shelf;
1111

12+
import '../../admin/actions/actions.dart';
1213
import '../../package/backend.dart';
1314
import '../../package/models.dart';
15+
import '../../package/overrides.dart';
1416
import '../../shared/configuration.dart';
1517
import '../../shared/redis_cache.dart';
1618
import '../../shared/urls.dart' as urls;
1719
import '../../shared/utils.dart';
1820
import '../dom/dom.dart' as d;
1921

20-
/// Handles requests for /feed.atom
21-
Future<shelf.Response> atomFeedHandler(shelf.Request request) async {
22+
/// Handles requests for `/feed.atom`
23+
Future<shelf.Response> allPackagesAtomFeedhandler(shelf.Request request) async {
2224
final feedContent =
23-
await cache.atomFeedXml().get(buildAllPackagesAtomFeedContent);
25+
await cache.allPackagesAtomFeedXml().get(buildAllPackagesAtomFeedContent);
26+
return shelf.Response.ok(
27+
feedContent,
28+
headers: {
29+
'content-type': 'application/atom+xml; charset="utf-8"',
30+
'x-content-type-options': 'nosniff',
31+
},
32+
);
33+
}
34+
35+
/// Handles requests for `/packages/<package>/feed.atom`
36+
Future<shelf.Response> packageAtomFeedhandler(
37+
shelf.Request request,
38+
String package,
39+
) async {
40+
checkPackageVersionParams(package);
41+
final feedContent = await cache
42+
.packageAtomFeedXml(package)
43+
.get(() => buildPackageAtomFeedContent(package));
2444
return shelf.Response.ok(
2545
feedContent,
2646
headers: {
@@ -33,7 +53,26 @@ Future<shelf.Response> atomFeedHandler(shelf.Request request) async {
3353
/// Builds the content of the /feed.atom endpoint.
3454
Future<String> buildAllPackagesAtomFeedContent() async {
3555
final versions = await packageBackend.latestPackageVersions(limit: 100);
36-
final feed = _feedFromPackageVersions(versions);
56+
versions.removeWhere((pv) => pv.isModerated || pv.isRetracted);
57+
final feed = _allPackagesFeed(versions);
58+
return feed.toXmlDocument();
59+
}
60+
61+
/// Builds the content of the `/packages/<package>/feed.atom` endpoint.
62+
Future<String> buildPackageAtomFeedContent(String package) async {
63+
if (isSoftRemoved(package) ||
64+
!await packageBackend.isPackageVisible(package)) {
65+
throw NotFoundException.resource(package);
66+
}
67+
final versions = await packageBackend
68+
.streamVersionsOfPackage(
69+
package,
70+
order: '-created',
71+
limit: 10,
72+
)
73+
.toList();
74+
versions.removeWhere((pv) => pv.isModerated || pv.isRetracted);
75+
final feed = _packageFeed(package, versions);
3776
return feed.toXmlDocument();
3877
}
3978

@@ -46,8 +85,15 @@ class FeedEntry {
4685
final String alternateUrl;
4786
final String? alternateTitle;
4887

49-
FeedEntry(this.id, this.title, this.updated, this.publisherId, this.content,
50-
this.alternateUrl, this.alternateTitle);
88+
FeedEntry({
89+
required this.id,
90+
required this.title,
91+
required this.updated,
92+
this.publisherId,
93+
required this.content,
94+
required this.alternateUrl,
95+
required this.alternateTitle,
96+
});
5197

5298
d.Node toNode() {
5399
return d.element(
@@ -75,27 +121,33 @@ class FeedEntry {
75121
class Feed {
76122
final String id;
77123
final String title;
78-
final String subTitle;
124+
final String? subTitle;
79125
final DateTime updated;
80-
final String author;
126+
final String? author;
81127
final String alternateUrl;
82128
final String selfUrl;
83129
final String generator;
84130
final String generatorVersion;
85131

86132
final List<FeedEntry> entries;
87133

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);
134+
Feed({
135+
required this.title,
136+
this.subTitle,
137+
this.author,
138+
required this.alternateUrl,
139+
required this.selfUrl,
140+
this.generator = 'Pub Feed Generator',
141+
this.generatorVersion = '0.1.0',
142+
required this.entries,
143+
}) : id = selfUrl,
144+
// Set the updated timestamp to the latest version timestamp. This prevents
145+
// unnecessary updates in the exported API bucket and makes tests consistent.
146+
updated = entries.isNotEmpty
147+
? entries
148+
.map((v) => v.updated)
149+
.reduce((a, b) => a.isAfter(b) ? a : b)
150+
: clock.now().toUtc();
99151

100152
String toXmlDocument() {
101153
final buffer = StringBuffer();
@@ -112,7 +164,8 @@ class Feed {
112164
d.element('id', text: id),
113165
d.element('title', text: title),
114166
d.element('updated', text: updated.toIso8601String()),
115-
d.element('author', child: d.element('name', text: author)),
167+
if (author != null)
168+
d.element('author', child: d.element('name', text: author)),
116169
d.element(
117170
'link',
118171
attributes: {'href': alternateUrl, 'rel': 'alternate'},
@@ -123,14 +176,14 @@ class Feed {
123176
attributes: {'version': generatorVersion},
124177
text: generator,
125178
),
126-
d.element('subtitle', text: subTitle),
179+
if (subTitle != null) d.element('subtitle', text: subTitle),
127180
...entries.map((e) => e.toNode()),
128181
],
129182
);
130183
}
131184
}
132185

133-
Feed _feedFromPackageVersions(List<PackageVersion> versions) {
186+
Feed _allPackagesFeed(List<PackageVersion> versions) {
134187
final entries = <FeedEntry>[];
135188
for (var i = 0; i < versions.length; i++) {
136189
final version = versions[i];
@@ -145,25 +198,59 @@ Feed _feedFromPackageVersions(List<PackageVersion> versions) {
145198
final id = createUuid(hash.bytes.sublist(0, 16));
146199
final title = 'v${version.version} of ${version.package}';
147200
final content = version.ellipsizedDescription ?? '[no description]';
148-
entries.add(FeedEntry(id, title, version.created!, version.publisherId,
149-
content, alternateUrl, alternateTitle));
201+
entries.add(FeedEntry(
202+
id: id,
203+
title: title,
204+
updated: version.created!,
205+
publisherId: version.publisherId,
206+
content: content,
207+
alternateUrl: alternateUrl,
208+
alternateTitle: alternateTitle,
209+
));
150210
}
151211

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);
212+
return Feed(
213+
title: 'Pub Packages for Dart',
214+
subTitle: 'Last Updated Packages',
215+
author: 'Dart Team',
216+
alternateUrl: activeConfiguration.primarySiteUri.resolve('/').toString(),
217+
selfUrl:
218+
activeConfiguration.primarySiteUri.resolve('/feed.atom').toString(),
219+
entries: entries,
220+
);
221+
}
222+
223+
Feed _packageFeed(String package, List<PackageVersion> versions) {
224+
return Feed(
225+
title: 'Most recently published versions for package $package',
226+
alternateUrl: activeConfiguration.primarySiteUri
227+
.resolve(urls.pkgPageUrl(package))
228+
.toString(),
229+
subTitle: versions.firstOrNull?.ellipsizedDescription,
230+
selfUrl: activeConfiguration.primarySiteUri
231+
.resolve(urls.pkgFeedUrl(package))
232+
.toString(),
233+
author: versions.firstOrNull?.publisherId,
234+
entries: versions.map((v) {
235+
final hash =
236+
sha512.convert(utf8.encode('package-feed/$package/${v.version}'));
237+
final id = createUuid(hash.bytes.sublist(0, 16));
238+
final alternateUrl = activeConfiguration.primarySiteUri
239+
.replace(
240+
path: urls.pkgPageUrl(
241+
package,
242+
version: v.version,
243+
))
244+
.toString();
245+
return FeedEntry(
246+
id: id,
247+
title: 'v${v.version} of $package',
248+
alternateUrl: alternateUrl,
249+
alternateTitle: v.version,
250+
content:
251+
'${v.version} was published on ${shortDateFormat.format(v.created!)}.',
252+
updated: v.created!,
253+
);
254+
}).toList(),
255+
);
169256
}

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
@@ -330,11 +330,16 @@ class CachePatterns {
330330
decode: (d) => d as bool,
331331
))[publisherId];
332332

333-
Entry<String> atomFeedXml() => _cache
333+
Entry<String> allPackagesAtomFeedXml() => _cache
334334
.withPrefix('atom-feed-xml/')
335335
.withTTL(Duration(minutes: 3))
336336
.withCodec(utf8)['/'];
337337

338+
Entry<String> packageAtomFeedXml(String package) => _cache
339+
.withPrefix('package-atom-feed-xml/')
340+
.withTTL(Duration(minutes: 10))
341+
.withCodec(utf8)[package];
342+
338343
Entry<List<int>> topicNameCompletionDataJsonGz() => _cache
339344
.withPrefix('topic-name-completion-data-json-gz/')
340345
.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,

0 commit comments

Comments
 (0)