Add a function to serve a single file (#22)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e725bda..e3ea97f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.2.6
+
+* Add a `createFileHandler()` function that serves a single static file.
+
## 0.2.5
* Add an optional `contentTypeResolver` argument to `createStaticHandler`.
diff --git a/lib/src/static_handler.dart b/lib/src/static_handler.dart
index 9f67de9..ed96c82 100644
--- a/lib/src/static_handler.dart
+++ b/lib/src/static_handler.dart
@@ -2,18 +2,22 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
+import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:convert/convert.dart';
import 'package:http_parser/http_parser.dart';
-import 'package:mime/mime.dart' as mime;
+import 'package:mime/mime.dart';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart';
import 'directory_listing.dart';
import 'util.dart';
+/// The default resolver for MIME types based on file extensions.
+final _defaultMimeTypeResolver = new MimeTypeResolver();
+
// TODO option to exclude hidden files?
/// Creates a Shelf [Handler] that serves files from the provided
@@ -40,7 +44,7 @@
String defaultDocument,
bool listDirectories: false,
bool useHeaderBytesForContentType: false,
- mime.MimeTypeResolver contentTypeResolver}) {
+ MimeTypeResolver contentTypeResolver}) {
var rootDir = new Directory(fileSystemPath);
if (!rootDir.existsSync()) {
throw new ArgumentError('A directory corresponding to fileSystemPath '
@@ -55,9 +59,9 @@
}
}
- contentTypeResolver ??= new mime.MimeTypeResolver();
+ contentTypeResolver ??= _defaultMimeTypeResolver;
- return (Request request) async {
+ return (Request request) {
var segs = [fileSystemPath]..addAll(request.url.pathSegments);
var fsPath = p.joinAll(segs);
@@ -98,41 +102,21 @@
return _redirectToAddTrailingSlash(uri);
}
- var fileStat = file.statSync();
- var ifModifiedSince = request.ifModifiedSince;
+ return _handleFile(request, file, () async {
+ if (useHeaderBytesForContentType) {
+ var length = math.min(
+ contentTypeResolver.magicNumbersMaxLength, file.lengthSync());
- if (ifModifiedSince != null) {
- var fileChangeAtSecResolution = toSecondResolution(fileStat.changed);
- if (!fileChangeAtSecResolution.isAfter(ifModifiedSince)) {
- return new Response.notModified();
+ var byteSink = new ByteAccumulatorSink();
+
+ await file.openRead(0, length).listen(byteSink.add).asFuture();
+
+ return contentTypeResolver.lookup(file.path,
+ headerBytes: byteSink.bytes);
+ } else {
+ return contentTypeResolver.lookup(file.path);
}
- }
-
- var headers = <String, String>{
- HttpHeaders.CONTENT_LENGTH: fileStat.size.toString(),
- HttpHeaders.LAST_MODIFIED: formatHttpDate(fileStat.changed)
- };
-
- String contentType;
- if (useHeaderBytesForContentType) {
- var length = math.min(
- contentTypeResolver.magicNumbersMaxLength, file.lengthSync());
-
- var byteSink = new ByteAccumulatorSink();
-
- await file.openRead(0, length).listen(byteSink.add).asFuture();
-
- contentType =
- contentTypeResolver.lookup(file.path, headerBytes: byteSink.bytes);
- } else {
- contentType = contentTypeResolver.lookup(file.path);
- }
-
- if (contentType != null) {
- headers[HttpHeaders.CONTENT_TYPE] = contentType;
- }
-
- return new Response.ok(file.openRead(), headers: headers);
+ });
};
}
@@ -161,3 +145,56 @@
return null;
}
+
+/// Creates a shelf [Handler] that serves the file at [path].
+///
+/// This returns a 404 response for any requests whose [Request.url] doesn't
+/// match [url]. The [url] defaults to the basename of [path].
+///
+/// This uses the given [contentType] for the Content-Type header. It defaults
+/// to looking up a content type based on [path]'s file extension, and failing
+/// that doesn't sent a [contentType] header at all.
+Handler createFileHandler(String path, {String url, String contentType}) {
+ var file = new File(path);
+ if (!file.existsSync()) {
+ throw new ArgumentError.value(path, 'path', 'does not exist.');
+ } else if (url != null && !p.url.isRelative(url)) {
+ throw new ArgumentError.value(url, 'url', 'must be relative.');
+ }
+
+ contentType ??= _defaultMimeTypeResolver.lookup(path);
+ url ??= p.toUri(p.basename(path)).toString();
+
+ return (request) {
+ if (request.url.path != url) return new Response.notFound('Not Found');
+ return _handleFile(request, file, () => contentType);
+ };
+}
+
+/// Serves the contents of [file] in response to [request].
+///
+/// This handles caching, and sends a 304 Not Modified response if the request
+/// indicates that it has the latest version of a file. Otherwise, it calls
+/// [getContentType] and uses it to populate the Content-Type header.
+Future<Response> _handleFile(
+ Request request, File file, FutureOr<String> getContentType()) async {
+ var stat = file.statSync();
+ var ifModifiedSince = request.ifModifiedSince;
+
+ if (ifModifiedSince != null) {
+ var fileChangeAtSecResolution = toSecondResolution(stat.changed);
+ if (!fileChangeAtSecResolution.isAfter(ifModifiedSince)) {
+ return new Response.notModified();
+ }
+ }
+
+ var headers = {
+ HttpHeaders.CONTENT_LENGTH: stat.size.toString(),
+ HttpHeaders.LAST_MODIFIED: formatHttpDate(stat.changed)
+ };
+
+ var contentType = await getContentType();
+ if (contentType != null) headers[HttpHeaders.CONTENT_TYPE] = contentType;
+
+ return new Response.ok(file.openRead(), headers: headers);
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 2618b35..9861dcb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,10 +1,10 @@
name: shelf_static
-version: 0.2.5
+version: 0.2.6
author: Dart Team <misc@dartlang.org>
description: Static file server support for Shelf
homepage: https://github.com/dart-lang/shelf_static
environment:
- sdk: '>=1.7.0 <2.0.0'
+ sdk: '>=1.22.0 <2.0.0'
dependencies:
convert: '>=1.0.0 <3.0.0'
http_parser: '>=0.0.2+2 <4.0.0'
diff --git a/test/create_file_handler_test.dart b/test/create_file_handler_test.dart
new file mode 100644
index 0000000..5b183e9
--- /dev/null
+++ b/test/create_file_handler_test.dart
@@ -0,0 +1,116 @@
+// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:http_parser/http_parser.dart';
+import 'package:mime/mime.dart' as mime;
+import 'package:path/path.dart' as p;
+import 'package:scheduled_test/descriptor.dart' as d;
+import 'package:scheduled_test/scheduled_test.dart';
+
+import 'package:shelf_static/shelf_static.dart';
+import 'test_util.dart';
+
+void main() {
+ var tempDir;
+ setUp(() {
+ schedule(() async {
+ tempDir =
+ (await Directory.systemTemp.createTemp('shelf_static-test-')).path;
+ d.defaultRoot = tempDir;
+ });
+
+ d.file('file.txt', 'contents').create();
+ d.file('random.unknown', 'no clue').create();
+
+ currentSchedule.onComplete.schedule(() async {
+ d.defaultRoot = null;
+ await new Directory(tempDir).delete(recursive: true);
+ });
+ });
+
+ test('serves the file contents', () {
+ schedule(() async {
+ var handler = createFileHandler(p.join(tempDir, 'file.txt'));
+ var response = await makeRequest(handler, '/file.txt');
+ expect(response.statusCode, equals(HttpStatus.OK));
+ expect(response.contentLength, equals(8));
+ expect(response.readAsString(), completion(equals('contents')));
+ });
+ });
+
+ test('serves a 404 for a non-matching URL', () {
+ schedule(() async {
+ var handler = createFileHandler(p.join(tempDir, 'file.txt'));
+ var response = await makeRequest(handler, '/foo/file.txt');
+ expect(response.statusCode, equals(HttpStatus.NOT_FOUND));
+ });
+ });
+
+ test('serves the file contents under a custom URL', () {
+ schedule(() async {
+ var handler =
+ createFileHandler(p.join(tempDir, 'file.txt'), url: 'foo/bar');
+ var response = await makeRequest(handler, '/foo/bar');
+ expect(response.statusCode, equals(HttpStatus.OK));
+ expect(response.contentLength, equals(8));
+ expect(response.readAsString(), completion(equals('contents')));
+ });
+ });
+
+ test("serves a 404 if the custom URL isn't matched", () {
+ schedule(() async {
+ var handler =
+ createFileHandler(p.join(tempDir, 'file.txt'), url: 'foo/bar');
+ var response = await makeRequest(handler, '/file.txt');
+ expect(response.statusCode, equals(HttpStatus.NOT_FOUND));
+ });
+ });
+
+ group('the content type header', () {
+ test('is inferred from the file path', () {
+ schedule(() async {
+ var handler = createFileHandler(p.join(tempDir, 'file.txt'));
+ var response = await makeRequest(handler, '/file.txt');
+ expect(response.statusCode, equals(HttpStatus.OK));
+ expect(response.mimeType, equals('text/plain'));
+ });
+ });
+
+ test("is omitted if it can't be inferred", () {
+ schedule(() async {
+ var handler = createFileHandler(p.join(tempDir, 'random.unknown'));
+ var response = await makeRequest(handler, '/random.unknown');
+ expect(response.statusCode, equals(HttpStatus.OK));
+ expect(response.mimeType, isNull);
+ });
+ });
+
+ test('comes from the contentType parameter', () {
+ schedule(() async {
+ var handler = createFileHandler(p.join(tempDir, 'file.txt'),
+ contentType: 'something/weird');
+ var response = await makeRequest(handler, '/file.txt');
+ expect(response.statusCode, equals(HttpStatus.OK));
+ expect(response.mimeType, equals('something/weird'));
+ });
+ });
+ });
+
+ group('throws an ArgumentError for', () {
+ test("a file that doesn't exist", () {
+ expect(() => createFileHandler(p.join(tempDir, 'nothing.txt')),
+ throwsArgumentError);
+ });
+
+ test("an absolute URL", () {
+ expect(
+ () => createFileHandler(p.join(tempDir, 'nothing.txt'),
+ url: '/foo/bar'),
+ throwsArgumentError);
+ });
+ });
+}