Skip to content

Commit 6c56649

Browse files
committed
Export /feed.atom to the exported bucket.
1 parent 877a4bc commit 6c56649

File tree

5 files changed

+101
-12
lines changed

5 files changed

+101
-12
lines changed

app/lib/frontend/handlers/atom_feed.dart

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ 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 = await cache.atomFeedXml().get();
2723
return shelf.Response.ok(
2824
feedContent,
2925
headers: {
@@ -33,6 +29,13 @@ Future<shelf.Response> atomFeedHandler(shelf.Request request) async {
3329
);
3430
}
3531

32+
/// Builds the content of the /feed.atom endpoint.
33+
Future<String> buildAllPackagesAtomFeedContent() async {
34+
final versions = await packageBackend.latestPackageVersions(limit: 100);
35+
final feed = _feedFromPackageVersions(versions);
36+
return feed.toXmlDocument();
37+
}
38+
3639
class FeedEntry {
3740
final String id;
3841
final String title;
@@ -126,10 +129,7 @@ class Feed {
126129
}
127130
}
128131

129-
Feed _feedFromPackageVersions(
130-
Uri requestedUri,
131-
List<PackageVersion> versions,
132-
) {
132+
Feed _feedFromPackageVersions(List<PackageVersion> versions) {
133133
final entries = <FeedEntry>[];
134134
for (var i = 0; i < versions.length; i++) {
135135
final version = versions[i];

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 synchornizeAllPackagesAtomFeed(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> synchornizeAllPackagesAtomFeed({
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!.synchornizeAllPackagesAtomFeed(),
1277+
],
12761278
]);
12771279
await tarballStorage.updateContentDispositionOnPublicBucket(
12781280
newVersion.package, newVersion.version!);

app/test/package/api_export/api_exporter_test.dart

Lines changed: 25 additions & 1 deletion
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,6 +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';
16+
import 'package:pub_dev/service/async_queue/async_queue.dart';
1517
import 'package:pub_dev/shared/datastore.dart';
1618
import 'package:pub_dev/shared/storage.dart';
1719
import 'package:pub_dev/shared/utils.dart';
@@ -131,6 +133,10 @@ Future<void> _testExportedApiSynchronization(
131133
await bucket.readBytes('$runtimeVersion/api/archives/foo-1.0.0.tar.gz'),
132134
isNotNull,
133135
);
136+
expect(
137+
await bucket.readString('$runtimeVersion/feed.atom'),
138+
contains('v1.0.0 of foo'),
139+
);
134140
}
135141

136142
_log.info('## New package');
@@ -160,6 +166,10 @@ Future<void> _testExportedApiSynchronization(
160166
await bucket.readBytes('latest/api/archives/foo-1.0.0.tar.gz'),
161167
isNotNull,
162168
);
169+
expect(
170+
await bucket.readString('$runtimeVersion/feed.atom'),
171+
contains('v1.0.0 of foo'),
172+
);
163173
// Note. that name completion data won't be updated until search caches
164174
// are purged, so we won't test that it is updated.
165175

@@ -176,6 +186,10 @@ Future<void> _testExportedApiSynchronization(
176186
await bucket.readBytes('latest/api/archives/bar-2.0.0.tar.gz'),
177187
isNotNull,
178188
);
189+
expect(
190+
await bucket.readString('$runtimeVersion/feed.atom'),
191+
contains('v2.0.0 of bar'),
192+
);
179193
}
180194

181195
_log.info('## New package version');
@@ -214,6 +228,10 @@ Future<void> _testExportedApiSynchronization(
214228
await bucket.readBytes('latest/api/archives/bar-3.0.0.tar.gz'),
215229
isNotNull,
216230
);
231+
expect(
232+
await bucket.readString('$runtimeVersion/feed.atom'),
233+
contains('v3.0.0 of bar'),
234+
);
217235
}
218236

219237
_log.info('## Discontinued flipped on');
@@ -439,7 +457,7 @@ Future<void> _testExportedApiSynchronization(
439457
}
440458

441459
extension on Bucket {
442-
/// Read bytes from bucket, retur null if missing
460+
/// Read bytes from bucket, return null if missing
443461
Future<Uint8List?> readBytes(String path) async {
444462
try {
445463
return await readAsBytes(path);
@@ -457,4 +475,10 @@ extension on Bucket {
457475
}
458476
return utf8JsonDecoder.convert(gzip.decode(bytes));
459477
}
478+
479+
/// Read bytes from bucket and decode as UTF-8 text.
480+
Future<String> readString(String path) async {
481+
final bytes = await readBytes(path);
482+
return utf8.decode(gzip.decode(bytes!));
483+
}
460484
}

0 commit comments

Comments
 (0)