Skip to content

Commit 32ade8b

Browse files
authored
Export /feed.atom to the exported bucket. (dart-lang#8702)
1 parent be466da commit 32ade8b

File tree

6 files changed

+137
-26
lines changed

6 files changed

+137
-26
lines changed

app/lib/admin/backend.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,8 @@ class AdminBackend {
400400
await _db
401401
.deleteWithQuery(_db.query<PackageVersion>(ancestorKey: packageKey));
402402

403+
await purgePackageCache(packageName);
404+
403405
_logger.info('Package "$packageName" got successfully removed.');
404406
return (
405407
deletedPackages: deletedPackages,

app/lib/frontend/handlers/atom_feed.dart

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,8 @@ import '../dom/dom.dart' as d;
1919

2020
/// Handles requests for /feed.atom
2121
Future<shelf.Response> atomFeedHandler(shelf.Request request) async {
22-
final feedContent = await cache.atomFeedXml().get(() async {
23-
final versions = await packageBackend.latestPackageVersions(limit: 100);
24-
final feed = _feedFromPackageVersions(request.requestedUri, versions);
25-
return feed.toXmlDocument();
26-
});
22+
final feedContent =
23+
await cache.atomFeedXml().get(buildAllPackagesAtomFeedContent);
2724
return shelf.Response.ok(
2825
feedContent,
2926
headers: {
@@ -33,6 +30,13 @@ Future<shelf.Response> atomFeedHandler(shelf.Request request) async {
3330
);
3431
}
3532

33+
/// Builds the content of the /feed.atom endpoint.
34+
Future<String> buildAllPackagesAtomFeedContent() async {
35+
final versions = await packageBackend.latestPackageVersions(limit: 100);
36+
final feed = _feedFromPackageVersions(versions);
37+
return feed.toXmlDocument();
38+
}
39+
3640
class FeedEntry {
3741
final String id;
3842
final String title;
@@ -126,10 +130,7 @@ class Feed {
126130
}
127131
}
128132

129-
Feed _feedFromPackageVersions(
130-
Uri requestedUri,
131-
List<PackageVersion> versions,
132-
) {
133+
Feed _feedFromPackageVersions(List<PackageVersion> versions) {
133134
final entries = <FeedEntry>[];
134135
for (var i = 0; i < versions.length; i++) {
135136
final version = versions[i];
@@ -157,7 +158,11 @@ Feed _feedFromPackageVersions(
157158
final alternateUrl =
158159
activeConfiguration.primarySiteUri.resolve('/').toString();
159160
final author = 'Dart Team';
160-
final updated = clock.now().toUtc();
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();
161166

162167
return Feed(id, title, subTitle, updated, author, alternateUrl, selfUrl,
163168
'Pub Feed Generator', '0.1.0', entries);

app/lib/package/api_export/api_exporter.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:clock/clock.dart';
99
import 'package:gcloud/service_scope.dart' as ss;
1010
import 'package:gcloud/storage.dart';
1111
import 'package:logging/logging.dart';
12+
import 'package:pub_dev/frontend/handlers/atom_feed.dart';
1213
import 'package:pub_dev/service/security_advisories/backend.dart';
1314
import 'package:pub_dev/shared/exceptions.dart';
1415
import 'package:pub_dev/shared/parallel_foreach.dart';
@@ -157,6 +158,7 @@ final class ApiExporter {
157158
});
158159

159160
await synchronizePackageNameCompletionData(forceWrite: forceWrite);
161+
await synchronizeAllPackagesAtomFeed(forceWrite: forceWrite);
160162

161163
await _api.notFound.write({
162164
'error': {
@@ -305,4 +307,14 @@ final class ApiExporter {
305307
await abort.future.timeout(Duration(minutes: 10), onTimeout: () => null);
306308
}
307309
}
310+
311+
/// Synchronize the `/feed.atom` file into [ExportedApi].
312+
Future<void> synchronizeAllPackagesAtomFeed({
313+
bool forceWrite = false,
314+
}) async {
315+
await _api.allPackagesFeedAtomFile.write(
316+
await buildAllPackagesAtomFeedContent(),
317+
forceWrite: forceWrite,
318+
);
319+
}
308320
}

app/lib/package/api_export/exported_api.dart

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ final class ExportedApi {
6666
Duration(hours: 8),
6767
);
6868

69+
/// Interface for writing `/feed.atom`
70+
ExportedAtomFeedFile get allPackagesFeedAtomFile =>
71+
ExportedAtomFeedFile._(this, '/feed.atom', Duration(hours: 12));
72+
6973
/// Interface for writing `/api/not-found.json` which is what the bucket will
7074
/// use as 404 response when serving a website.
7175
ExportedJsonFile<Map<String, Object?>> get notFound =>
@@ -502,7 +506,7 @@ final class ExportedJsonFile<T> extends ExportedObject {
502506

503507
/// Write [data] as gzipped JSON in UTF-8 format.
504508
///
505-
/// This will only write of `Content-Length` and `md5Hash` doesn't match the
509+
/// This will only write if `Content-Length` and `md5Hash` doesn't match the
506510
/// existing file, or if [forceWrite] is given.
507511
Future<void> write(T data, {bool forceWrite = false}) async {
508512
final gzipped = _jsonGzip.encode(data);
@@ -521,6 +525,53 @@ final class ExportedJsonFile<T> extends ExportedObject {
521525
}
522526
}
523527

528+
/// Interface for an exported atom feed file.
529+
///
530+
/// This will write an atom feed as gzipped UTF-8, adding headers for
531+
/// * `Content-Type`,
532+
/// * `Content-Encoding`, and,
533+
/// * `Cache-Control`.
534+
final class ExportedAtomFeedFile<T> extends ExportedObject {
535+
final Duration _maxAge;
536+
537+
ExportedAtomFeedFile._(
538+
super._owner,
539+
super._objectName,
540+
this._maxAge,
541+
) : super._();
542+
543+
ObjectMetadata _metadata() {
544+
return ObjectMetadata(
545+
contentType: 'application/atom+xml; charset="utf-8"',
546+
contentEncoding: 'gzip',
547+
cacheControl: 'public, max-age=${_maxAge.inSeconds}',
548+
custom: {
549+
_validatedCustomHeader: clock.now().toIso8601String(),
550+
},
551+
);
552+
}
553+
554+
/// Write [content] as gzipped text in UTF-8 format.
555+
///
556+
/// This will only write if `Content-Length` and `md5Hash` doesn't match the
557+
/// existing file, or if [forceWrite] is given.
558+
Future<void> write(String content, {bool forceWrite = false}) async {
559+
final gzipped = gzip.encode(utf8.encode(content));
560+
final metadata = _metadata();
561+
562+
await Future.wait(_owner._prefixes.map((prefix) async {
563+
await _owner._pool.withResource(() async {
564+
await _owner._bucket.writeBytesIfDifferent(
565+
prefix + _objectName,
566+
gzipped,
567+
metadata,
568+
forceWrite: forceWrite,
569+
);
570+
});
571+
}));
572+
}
573+
}
574+
524575
/// Interface for an exported binary file.
525576
///
526577
/// This will write a binary blob as is, adding headers for

app/lib/package/backend.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1271,8 +1271,10 @@ class PackageBackend {
12711271
if (activeConfiguration.isPublishedEmailNotificationEnabled)
12721272
emailBackend.trySendOutgoingEmail(outgoingEmail),
12731273
taskBackend.trackPackage(newVersion.package, updateDependents: true),
1274-
if (apiExporter != null)
1274+
if (apiExporter != null) ...[
12751275
apiExporter!.synchronizePackage(newVersion.package),
1276+
apiExporter!.synchronizeAllPackagesAtomFeed(),
1277+
],
12761278
]);
12771279
await tarballStorage.updateContentDispositionOnPublicBucket(
12781280
newVersion.package, newVersion.version!);

app/test/package/api_export/api_exporter_test.dart

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:convert';
56
import 'dart:io';
67
import 'dart:typed_data';
78

@@ -12,7 +13,7 @@ import 'package:googleapis/storage/v1.dart' show DetailedApiRequestError;
1213
import 'package:logging/logging.dart';
1314
import 'package:pub_dev/fake/backend/fake_auth_provider.dart';
1415
import 'package:pub_dev/package/api_export/api_exporter.dart';
15-
import 'package:pub_dev/shared/datastore.dart';
16+
import 'package:pub_dev/shared/configuration.dart';
1617
import 'package:pub_dev/shared/storage.dart';
1718
import 'package:pub_dev/shared/utils.dart';
1819
import 'package:pub_dev/shared/versions.dart';
@@ -48,15 +49,19 @@ void main() {
4849
'SHOUT Deleting object from public bucket: "packages/bar-2.0.0.tar.gz".',
4950
'SHOUT Deleting object from public bucket: "packages/bar-3.0.0.tar.gz".',
5051
], (fakeTime) async {
51-
await storageService.createBucket('bucket');
52-
final bucket = storageService.bucket('bucket');
53-
final apiExporter =
54-
ApiExporter(dbService, storageService: storageService, bucket: bucket);
52+
// Since we want to verify post-upload tasks triggering API exporter,
53+
// we cannot use an isolated instance, we need to use the same setup.
54+
// However, for better control and consistency, we can remove all the
55+
// existing files from the bucket at the start of this test:
56+
await apiExporter!.stop();
57+
final bucket =
58+
storageService.bucket(activeConfiguration.exportedApiBucketName!);
59+
await _deleteAll(bucket);
5560

5661
await _testExportedApiSynchronization(
5762
fakeTime,
5863
bucket,
59-
apiExporter.synchronizeExportedApi,
64+
apiExporter!.synchronizeExportedApi,
6065
);
6166
});
6267

@@ -68,26 +73,38 @@ void main() {
6873
],
6974
testProfile: _testProfile,
7075
(fakeTime) async {
71-
await storageService.createBucket('bucket');
72-
final bucket = storageService.bucket('bucket');
73-
final apiExporter = ApiExporter(dbService,
74-
storageService: storageService, bucket: bucket);
76+
// Since we want to verify post-upload tasks triggering API exporter,
77+
// we cannot use an isolated instance, we need to use the same setup.
78+
// However, for better control and consistency, we can remove all the
79+
// existing files from the bucket at the start of this test:
80+
await apiExporter!.stop();
81+
final bucket =
82+
storageService.bucket(activeConfiguration.exportedApiBucketName!);
83+
await _deleteAll(bucket);
7584

76-
await apiExporter.synchronizeExportedApi();
85+
await apiExporter!.synchronizeExportedApi();
7786

78-
await apiExporter.start();
87+
await apiExporter!.start();
7988

8089
await _testExportedApiSynchronization(
8190
fakeTime,
8291
bucket,
8392
() async => await fakeTime.elapse(minutes: 15),
8493
);
8594

86-
await apiExporter.stop();
95+
await apiExporter!.stop();
8796
},
8897
);
8998
}
9099

100+
Future<void> _deleteAll(Bucket bucket) async {
101+
await for (final entry in bucket.list(delimiter: '')) {
102+
if (entry.isObject) {
103+
await bucket.delete(entry.name);
104+
}
105+
}
106+
}
107+
91108
Future<void> _testExportedApiSynchronization(
92109
FakeTime fakeTime,
93110
Bucket bucket,
@@ -131,6 +148,10 @@ Future<void> _testExportedApiSynchronization(
131148
await bucket.readBytes('$runtimeVersion/api/archives/foo-1.0.0.tar.gz'),
132149
isNotNull,
133150
);
151+
expect(
152+
await bucket.readString('$runtimeVersion/feed.atom'),
153+
contains('v1.0.0 of foo'),
154+
);
134155
}
135156

136157
_log.info('## New package');
@@ -160,6 +181,10 @@ Future<void> _testExportedApiSynchronization(
160181
await bucket.readBytes('latest/api/archives/foo-1.0.0.tar.gz'),
161182
isNotNull,
162183
);
184+
expect(
185+
await bucket.readString('latest/feed.atom'),
186+
contains('v1.0.0 of foo'),
187+
);
163188
// Note. that name completion data won't be updated until search caches
164189
// are purged, so we won't test that it is updated.
165190

@@ -176,6 +201,10 @@ Future<void> _testExportedApiSynchronization(
176201
await bucket.readBytes('latest/api/archives/bar-2.0.0.tar.gz'),
177202
isNotNull,
178203
);
204+
expect(
205+
await bucket.readString('latest/feed.atom'),
206+
contains('v2.0.0 of bar'),
207+
);
179208
}
180209

181210
_log.info('## New package version');
@@ -214,6 +243,10 @@ Future<void> _testExportedApiSynchronization(
214243
await bucket.readBytes('latest/api/archives/bar-3.0.0.tar.gz'),
215244
isNotNull,
216245
);
246+
expect(
247+
await bucket.readString('$runtimeVersion/feed.atom'),
248+
contains('v3.0.0 of bar'),
249+
);
217250
}
218251

219252
_log.info('## Discontinued flipped on');
@@ -439,7 +472,7 @@ Future<void> _testExportedApiSynchronization(
439472
}
440473

441474
extension on Bucket {
442-
/// Read bytes from bucket, retur null if missing
475+
/// Read bytes from bucket, return null if missing
443476
Future<Uint8List?> readBytes(String path) async {
444477
try {
445478
return await readAsBytes(path);
@@ -457,4 +490,10 @@ extension on Bucket {
457490
}
458491
return utf8JsonDecoder.convert(gzip.decode(bytes));
459492
}
493+
494+
/// Read bytes from bucket and decode as UTF-8 text.
495+
Future<String> readString(String path) async {
496+
final bytes = await readBytes(path);
497+
return utf8.decode(gzip.decode(bytes!));
498+
}
460499
}

0 commit comments

Comments
 (0)