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);
+    });
+  });
+}