v0.1.4 - createStaticHandler learned defaultDocument

Resolves kevmoo/shelf_static.dart#1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e16584..6165e47 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.1.4
+
+* Added named (optional) `defaultDocument` argument to `createStaticHandler`.
+
 ## 0.1.3
 
 * `createStaticHandler` added `serveFilesOutsidePath` optional parameter.
diff --git a/example/example_server.dart b/example/example_server.dart
index 3331b64..3c050eb 100644
--- a/example/example_server.dart
+++ b/example/example_server.dart
@@ -11,7 +11,8 @@
         'root of the project.');
   }
   var handler = const shelf.Pipeline().addMiddleware(shelf.logRequests())
-      .addHandler(createStaticHandler('example/files'));
+      .addHandler(createStaticHandler('example/files',
+          defaultDocument: 'index.html'));
 
   io.serve(handler, 'localhost', 8080).then((server) {
     print('Serving at http://${server.address.host}:${server.port}');
diff --git a/lib/shelf_static.dart b/lib/shelf_static.dart
index bf42a41..ebdea75 100644
--- a/lib/shelf_static.dart
+++ b/lib/shelf_static.dart
@@ -8,7 +8,6 @@
 import 'package:shelf/shelf.dart';
 
 // directory listing
-// default document
 // hidden files
 
 /// Creates a Shelf [Handler] that serves files from the provided
@@ -17,8 +16,12 @@
 /// Accessing a path containing symbolic links will succeed only if the resolved
 /// path is within [fileSystemPath]. To allow access to paths outside of
 /// [fileSystemPath], set [serveFilesOutsidePath] to `true`.
+///
+/// When a existing directory is requested and a [defaultDocument] is specified
+/// the directory is checked for a file with that name. If it exists, it is
+/// served.
 Handler createStaticHandler(String fileSystemPath,
-    {bool serveFilesOutsidePath: false}) {
+    {bool serveFilesOutsidePath: false, String defaultDocument}) {
   var rootDir = new Directory(fileSystemPath);
   if (!rootDir.existsSync()) {
     throw new ArgumentError('A directory corresponding to fileSystemPath '
@@ -27,6 +30,12 @@
 
   fileSystemPath = rootDir.resolveSymbolicLinksSync();
 
+  if (defaultDocument != null) {
+    if (defaultDocument != p.basename(defaultDocument)) {
+      throw new ArgumentError('defaultDocument must be a file name.');
+    }
+  }
+
   return (Request request) {
     // TODO: expand these checks and/or follow updates to Uri class to be more
     //       strict. https://code.google.com/p/dart/issues/detail?id=16081
@@ -37,9 +46,18 @@
     var segs = [fileSystemPath]..addAll(request.url.pathSegments);
 
     var requestedPath = p.joinAll(segs);
-    var file = new File(requestedPath);
 
-    if (!file.existsSync()) {
+    var fileType = FileSystemEntity.typeSync(requestedPath, followLinks: true);
+
+    File file = null;
+
+    if (fileType == FileSystemEntityType.FILE) {
+      file = new File(requestedPath);
+    } else if (fileType == FileSystemEntityType.DIRECTORY) {
+      file = _tryDefaultFile(requestedPath, defaultDocument);
+    }
+
+    if (file == null) {
       return new Response.notFound('Not Found');
     }
 
@@ -52,6 +70,19 @@
       }
     }
 
+    if (fileType == FileSystemEntityType.DIRECTORY &&
+        !request.url.path.endsWith('/')) {
+      // when serving the default document for a directory, if the requested
+      // path doesn't end with '/', redirect to the path with a trailing '/'
+      var uri = request.requestedUri;
+      assert(!uri.path.endsWith('/'));
+      var location = new Uri(scheme: uri.scheme, userInfo: uri.userInfo,
+          host: uri.host, port: uri.port, path: uri.path + '/',
+          query: uri.query);
+
+      return new Response.movedPermanently(location.toString());
+    }
+
     var fileStat = file.statSync();
 
     var ifModifiedSince = request.ifModifiedSince;
@@ -75,6 +106,20 @@
   };
 }
 
+File _tryDefaultFile(String dirPath, String defaultFile) {
+  if (defaultFile == null) return null;
+
+  var filePath = p.join(dirPath, defaultFile);
+
+  var file = new File(filePath);
+
+  if (file.existsSync()) {
+    return file;
+  }
+
+  return null;
+}
+
 /// Use [createStaticHandler] instead.
 @deprecated
 Handler getHandler(String fileSystemPath) =>
diff --git a/pubspec.yaml b/pubspec.yaml
index ef404f8..ddb208e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: shelf_static
-version: 0.1.3
+version: 0.1.4
 author: Kevin Moore <github@j832.com>
 description: Static file server support for Shelf
 homepage: https://github.com/kevmoo/shelf_static.dart
diff --git a/test/default_document_test.dart b/test/default_document_test.dart
new file mode 100644
index 0000000..ada30c5
--- /dev/null
+++ b/test/default_document_test.dart
@@ -0,0 +1,145 @@
+library shelf_static.default_document_test;
+
+import 'dart:io';
+//import 'package:http_parser/http_parser.dart';
+//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() {
+  setUp(() {
+    var tempDir;
+    schedule(() {
+      return Directory.systemTemp.createTemp('shelf_static-test-').then((dir) {
+        tempDir = dir;
+        d.defaultRoot = tempDir.path;
+      });
+    });
+
+    d.file('index.html', '<html></html>').create();
+    d.file('root.txt', 'root txt').create();
+    d.dir('files', [
+        d.file('index.html', '<html><body>files</body></html>'),
+        d.file('with space.txt', 'with space content')
+    ]).create();
+
+    currentSchedule.onComplete.schedule(() {
+      d.defaultRoot = null;
+      return tempDir.delete(recursive: true);
+    });
+  });
+
+  group('default document value', () {
+    test('cannot contain slashes', () {
+      var invalidValues = ['file/foo.txt', '/bar.txt', '//bar.txt',
+                           '//news/bar.txt', 'foo/../bar.txt'];
+
+      for(var val in invalidValues) {
+        expect(() => createStaticHandler(d.defaultRoot, defaultDocument: val),
+            throwsArgumentError);
+      }
+    });
+  });
+
+  group('no default document specified', () {
+    test('access "/index.html"', () {
+      schedule(() {
+        var handler = createStaticHandler(d.defaultRoot);
+
+        return makeRequest(handler, '/index.html').then((response) {
+          expect(response.statusCode, HttpStatus.OK);
+          expect(response.contentLength, 13);
+          expect(response.readAsString(), completion('<html></html>'));
+        });
+      });
+    });
+
+    test('access "/"', () {
+      schedule(() {
+        var handler = createStaticHandler(d.defaultRoot);
+
+        return makeRequest(handler, '/').then((response) {
+          expect(response.statusCode, HttpStatus.NOT_FOUND);
+        });
+      });
+    });
+
+    test('access "/files"', () {
+      schedule(() {
+        var handler = createStaticHandler(d.defaultRoot);
+
+        return makeRequest(handler, '/files').then((response) {
+          expect(response.statusCode, HttpStatus.NOT_FOUND);
+        });
+      });
+    });
+
+    test('access "/files/" dir', () {
+      schedule(() {
+        var handler = createStaticHandler(d.defaultRoot);
+
+        return makeRequest(handler, '/files/').then((response) {
+          expect(response.statusCode, HttpStatus.NOT_FOUND);
+        });
+      });
+    });
+  });
+
+  group('default document specified', () {
+    test('access "/index.html"', () {
+      schedule(() {
+        var handler = createStaticHandler(d.defaultRoot,
+            defaultDocument: 'index.html');
+
+        return makeRequest(handler, '/index.html').then((response) {
+          expect(response.statusCode, HttpStatus.OK);
+          expect(response.contentLength, 13);
+          expect(response.readAsString(), completion('<html></html>'));
+        });
+      });
+    });
+
+    test('access "/"', () {
+      schedule(() {
+        var handler = createStaticHandler(d.defaultRoot,
+            defaultDocument: 'index.html');
+
+        return makeRequest(handler, '/').then((response) {
+          expect(response.statusCode, HttpStatus.OK);
+          expect(response.contentLength, 13);
+          expect(response.readAsString(), completion('<html></html>'));
+        });
+      });
+    });
+
+    test('access "/files"', () {
+      schedule(() {
+        var handler = createStaticHandler(d.defaultRoot,
+            defaultDocument: 'index.html');
+
+        return makeRequest(handler, '/files').then((response) {
+          expect(response.statusCode, HttpStatus.MOVED_PERMANENTLY);
+          expect(response.headers,
+              containsPair(HttpHeaders.LOCATION, 'http://localhost/files/'));
+        });
+      });
+    });
+
+    test('access "/files/" dir', () {
+      schedule(() {
+        var handler = createStaticHandler(d.defaultRoot,
+            defaultDocument: 'index.html');
+
+        return makeRequest(handler, '/files/').then((response) {
+          expect(response.statusCode, HttpStatus.OK);
+          expect(response.contentLength, 31);
+          expect(response.readAsString(),
+              completion('<html><body>files</body></html>'));
+        });
+      });
+    });
+  });
+}
diff --git a/test/harness_console.dart b/test/harness_console.dart
index 1a2d9e8..742ff7a 100644
--- a/test/harness_console.dart
+++ b/test/harness_console.dart
@@ -4,6 +4,7 @@
 
 import 'alternative_root_test.dart' as alternative_root;
 import 'basic_file_test.dart' as basic_file;
+import 'default_document_test.dart' as default_document;
 import 'get_handler_test.dart' as get_handler;
 import 'sample_test.dart' as sample;
 import 'symbolic_link_test.dart' as symbolic_link;
@@ -12,6 +13,7 @@
   groupSep = ' - ';
   group('alternative_root', alternative_root.main);
   group('basic_file', basic_file.main);
+  group('default_document', default_document.main);
   group('get_handler', get_handler.main);
   group('sample', sample.main);
   group('symbolic_link', symbolic_link.main);