Skip to content

Commit 2cc31a1

Browse files
committed
Implementing redirect on dartdoc-generated redirect file.
1 parent b7816e5 commit 2cc31a1

File tree

6 files changed

+236
-52
lines changed

6 files changed

+236
-52
lines changed

app/lib/dartdoc/models.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,52 @@ class ResolvedDocUrlVersion {
4242
bool get isEmpty => version.isEmpty || urlSegment.isEmpty;
4343
bool get isLatestStable => urlSegment == 'latest';
4444
}
45+
46+
/// Describes the status of a dartdoc page.
47+
@JsonSerializable(includeIfNull: false)
48+
class DocPageStatus {
49+
final DocPageStatusCode code;
50+
final String? redirectPath;
51+
final String? errorMessage;
52+
53+
DocPageStatus({
54+
required this.code,
55+
this.redirectPath,
56+
this.errorMessage,
57+
});
58+
59+
factory DocPageStatus.ok() {
60+
return DocPageStatus(code: DocPageStatusCode.ok);
61+
}
62+
63+
factory DocPageStatus.redirect(String redirectPath) {
64+
return DocPageStatus(
65+
code: DocPageStatusCode.redirect,
66+
redirectPath: redirectPath,
67+
);
68+
}
69+
70+
factory DocPageStatus.missing(String errorMessage) {
71+
return DocPageStatus(
72+
code: DocPageStatusCode.missing,
73+
errorMessage: errorMessage,
74+
);
75+
}
76+
77+
factory DocPageStatus.fromJson(Map<String, dynamic> json) =>
78+
_$DocPageStatusFromJson(json);
79+
80+
Map<String, dynamic> toJson() => _$DocPageStatusToJson(this);
81+
}
82+
83+
/// Explicit status of [DocPageStatus].
84+
enum DocPageStatusCode {
85+
/// page generated and ready to be served
86+
ok,
87+
88+
/// page generated and redirects to a new URL
89+
redirect,
90+
91+
/// page does not exists
92+
missing,
93+
}

app/lib/dartdoc/models.g.dart

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/lib/shared/redis_cache.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,22 @@ class CachePatterns {
378378
.withPrefix('dartdoc-html/')
379379
.withTTL(Duration(minutes: 10))['$package/$urlSegment/$path'];
380380

381+
/// Cache for sanitized and re-rendered dartdoc HTML files.
382+
Entry<DocPageStatus> dartdocPageStatus(
383+
String package,
384+
String urlSegment,
385+
String path,
386+
) =>
387+
_cache
388+
.withPrefix('dartdoc-status/')
389+
.withTTL(Duration(minutes: 10))
390+
.withCodec(utf8)
391+
.withCodec(json)
392+
.withCodec(wrapAsCodec(
393+
encode: (DocPageStatus s) => s.toJson(),
394+
decode: (data) => DocPageStatus.fromJson(
395+
data as Map<String, dynamic>)))['$package/$urlSegment/$path'];
396+
381397
/// Stores the OpenID Data (including the JSON Web Key list).
382398
///
383399
/// GitHub does not provide `Cache-Control` header for their

app/lib/task/backend.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,18 @@ class TaskBackend {
869869
return index;
870870
}
871871

872+
/// Whether the task result for the given [package]/[version] has any result
873+
/// dartdoc-generated file in [path].
874+
Future<bool> hasDartdocTaskResult(
875+
String package, String version, String path) async {
876+
version = canonicalizeVersion(version)!;
877+
final index = await _taskResultIndex(package, version);
878+
if (index == null) {
879+
return false;
880+
}
881+
return index.lookup('doc/$path') != null;
882+
}
883+
872884
/// Return gzipped result from task for the given [package]/[version] or
873885
/// `null`.
874886
Future<List<int>?> gzippedTaskResult(

app/lib/task/handlers.dart

Lines changed: 118 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:convert';
22
import 'dart:io' show gzip;
33

4+
import 'package:logging/logging.dart';
45
import 'package:path/path.dart' as p;
56

67
import 'package:pub_dev/dartdoc/dartdoc_page.dart';
@@ -42,6 +43,8 @@ const _safeMimeTypes = {
4243
// should not add it here.
4344
};
4445

46+
final _logger = Logger('task.handlers');
47+
4548
Future<shelf.Response> handleDartDoc(
4649
shelf.Request request,
4750
String package,
@@ -80,26 +83,75 @@ Future<shelf.Response> handleDartDoc(
8083
// Handle HTML requests
8184
final isHtml = ext == 'html';
8285
if (isHtml) {
83-
final htmlBytes = await cache
84-
.dartdocHtmlBytes(package, resolvedDocUrlVersion.urlSegment, path)
85-
.get(() async {
86+
// try cached bytes first
87+
final htmlBytesCacheEntry =
88+
cache.dartdocHtmlBytes(package, resolvedDocUrlVersion.urlSegment, path);
89+
final htmlBytes = await htmlBytesCacheEntry.get();
90+
if (htmlBytes != null) {
91+
return htmlBytesResponse(htmlBytes);
92+
}
93+
94+
// check cached status for redirect or missing pages
95+
final statusCacheEntry = cache.dartdocPageStatus(
96+
package, resolvedDocUrlVersion.urlSegment, path);
97+
final cachedStatus = await statusCacheEntry.get();
98+
final cachedStatusCode = cachedStatus?.code;
99+
100+
shelf.Response redirectPathResponse(String redirectPath) {
101+
final newPath = p.normalize(p.joinAll([
102+
p.dirname(path),
103+
redirectPath,
104+
if (!redirectPath.endsWith('.html')) 'index.html',
105+
]));
106+
return redirectResponse(pkgDocUrl(
107+
package,
108+
version: resolvedDocUrlVersion.urlSegment,
109+
relativePath: newPath,
110+
));
111+
}
112+
113+
if (cachedStatusCode == DocPageStatusCode.redirect) {
114+
final redirectPath = cachedStatus!.redirectPath;
115+
if (redirectPath == null) {
116+
_logger.shout('DocPageStatus redirect without path.');
117+
return notFoundHandler(request);
118+
}
119+
return redirectPathResponse(redirectPath);
120+
}
121+
122+
if (cachedStatusCode == DocPageStatusCode.missing) {
123+
return notFoundHandler(request,
124+
body: cachedStatus!.errorMessage ?? 'Documentation page not found.');
125+
}
126+
127+
// try loading bytes;
128+
final (status, bytes) = await () async {
129+
final dataGz = await taskBackend.dartdocFile(package, version, path);
130+
if (dataGz == null) {
131+
return (
132+
DocPageStatus(
133+
code: DocPageStatusCode.missing,
134+
errorMessage: await _missingPageErrorMessage(
135+
package,
136+
version,
137+
isLatestStable: resolvedDocUrlVersion.isLatestStable,
138+
),
139+
),
140+
null, // bytes
141+
);
142+
}
143+
86144
try {
87-
final dataGz = await taskBackend.dartdocFile(package, version, path);
88-
if (dataGz == null) {
89-
return const <int>[]; // store empty string for missing data
90-
}
91145
final dataJson = gzippedUtf8JsonCodec.decode(dataGz);
92146
if (path.endsWith('-sidebar.html')) {
93147
final sidebar =
94148
DartDocSidebar.fromJson(dataJson as Map<String, dynamic>);
95-
return utf8.encode(sidebar.content);
149+
return (DocPageStatus.ok(), utf8.encode(sidebar.content));
96150
}
97-
var page = DartDocPage.fromJson(dataJson as Map<String, dynamic>);
98151

99-
// NOTE: If the loaded page is redirecting, we try to load it and render
100-
// the page it is pointing to. Instead, we should redirect the page
101-
// to the new URL.
102-
// TODO: restructure cache logic and implement proper redirect
152+
final page = DartDocPage.fromJson(dataJson as Map<String, dynamic>);
153+
154+
// check for redirect page
103155
final redirectPath = page.redirectPath;
104156
if (page.isEmpty() &&
105157
redirectPath != null &&
@@ -109,11 +161,14 @@ Future<shelf.Response> handleDartDoc(
109161
redirectPath,
110162
if (!redirectPath.endsWith('.html')) 'index.html',
111163
]));
112-
final newDataGz =
113-
await taskBackend.dartdocFile(package, version, newPath);
114-
if (newDataGz != null) {
115-
final newDataJson = gzippedUtf8JsonCodec.decode(newDataGz);
116-
page = DartDocPage.fromJson(newDataJson as Map<String, dynamic>);
164+
if (await taskBackend.hasDartdocTaskResult(
165+
package, version, newPath)) {
166+
return (DocPageStatus.redirect(redirectPath), null);
167+
} else {
168+
return (
169+
DocPageStatus.missing('Dartdoc redirect is missing.'),
170+
null, // bytes
171+
);
117172
}
118173
}
119174

@@ -125,42 +180,25 @@ Future<shelf.Response> handleDartDoc(
125180
path: path,
126181
searchQueryParameter: searchQueryParameter,
127182
));
128-
return utf8.encode(html.toString());
129-
} on FormatException {
130-
// store empty string for invalid data, we treat it as a bug in
131-
// the documentation generation.
132-
return const <int>[];
133-
}
134-
});
135-
// We use empty string to indicate missing file or bug in the file
136-
if (htmlBytes == null || htmlBytes.isEmpty) {
137-
final status = await taskBackend.packageStatus(package);
138-
final vs = status.versions[version];
139-
if (vs == null) {
140-
return notFoundHandler(
141-
request,
142-
body: resolvedDocUrlVersion.isLatestStable
143-
? 'Analysis has not started yet.'
144-
: 'Version not selected for analysis.',
145-
);
183+
return (DocPageStatus.ok(), utf8.encode(html.toString()));
184+
} on FormatException catch (e, st) {
185+
_logger.shout(
186+
'Unable to parse dartdoc page for $package $version', e, st);
187+
return (DocPageStatus.missing('Unable to render page.'), null);
146188
}
147-
String? message;
148-
switch (vs.status) {
149-
case PackageVersionStatus.pending:
150-
case PackageVersionStatus.running:
151-
message = 'Analysis has not finished yet.';
152-
break;
153-
case PackageVersionStatus.failed:
154-
message =
155-
'Analysis has failed, no `dartdoc` output has been generated.';
156-
break;
157-
case PackageVersionStatus.completed:
158-
message = '`dartdoc` did not generate this page.';
159-
break;
160-
}
161-
return notFoundHandler(request, body: message);
189+
}();
190+
await statusCacheEntry.set(status);
191+
192+
switch (status.code) {
193+
case DocPageStatusCode.ok:
194+
await htmlBytesCacheEntry.set(bytes!);
195+
return htmlBytesResponse(bytes);
196+
case DocPageStatusCode.redirect:
197+
return redirectPathResponse(status.redirectPath!);
198+
case DocPageStatusCode.missing:
199+
return notFoundHandler(request,
200+
body: status.errorMessage ?? 'Documentation page not found.');
162201
}
163-
return htmlBytesResponse(htmlBytes);
164202
}
165203

166204
// Handle any non-HTML request
@@ -191,6 +229,34 @@ Future<shelf.Response> handleDartDoc(
191229
);
192230
}
193231

232+
Future<String> _missingPageErrorMessage(
233+
String package,
234+
String version, {
235+
required bool isLatestStable,
236+
}) async {
237+
final status = await taskBackend.packageStatus(package);
238+
final vs = status.versions[version];
239+
if (vs == null) {
240+
return isLatestStable
241+
? 'Analysis has not started yet.'
242+
: 'Version not selected for analysis.';
243+
}
244+
String? message;
245+
switch (vs.status) {
246+
case PackageVersionStatus.pending:
247+
case PackageVersionStatus.running:
248+
message = 'Analysis has not finished yet.';
249+
break;
250+
case PackageVersionStatus.failed:
251+
message = 'Analysis has failed, no `dartdoc` output has been generated.';
252+
break;
253+
case PackageVersionStatus.completed:
254+
message = '`dartdoc` did not generate this page.';
255+
break;
256+
}
257+
return message;
258+
}
259+
194260
/// Handles GET `/packages/<package>/versions/<version>/gen-res/<path|[^]*>`
195261
Future<shelf.Response> handleTaskResource(
196262
shelf.Request request,

pkg/pub_integration/test/dartdoc_search_test.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ void main() {
6060
startsWith('$origin/documentation/oxygen/latest/oxygen/'));
6161
expect(page.url, endsWith('.html'));
6262
});
63+
64+
// test library page (future redirect)
65+
await user.withBrowserPage((page) async {
66+
await page.gotoOrigin(
67+
'/documentation/oxygen/latest/oxygen/oxygen-library.html');
68+
await Future.delayed(Duration(milliseconds: 200));
69+
// NOTE: This test does nothing substantial right now, but once we migrate
70+
// to dartdoc 8.3, this will test the redirect handler.
71+
expect(page.url,
72+
'$origin/documentation/oxygen/latest/oxygen/oxygen-library.html');
73+
expect(await page.content, contains('TypeEnum'));
74+
});
6375
});
6476
}, timeout: Timeout.factor(testTimeoutFactor));
6577
}

0 commit comments

Comments
 (0)