Refactor range support implementation (#66)

Reduce nesting by extracting to a method allowing guard clauses and
inverted the conditionals with early returns. Remove some comments that
repeat the code. Use conditional map elements and `..[]` operator to
reduce the noise around setting headers.

Add tests for requests where the range header is ignored.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a885d2a..8be7a56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,5 @@
+## 1.1.1-dev
+
 ## 1.1.0
 
 * Correctly handle `HEAD` requests.
diff --git a/lib/src/static_handler.dart b/lib/src/static_handler.dart
index c290d95..463c731 100644
--- a/lib/src/static_handler.dart
+++ b/lib/src/static_handler.dart
@@ -188,74 +188,71 @@
     }
   }
 
+  final contentType = await getContentType();
   final headers = {
     HttpHeaders.lastModifiedHeader: formatHttpDate(stat.modified),
     HttpHeaders.acceptRangesHeader: 'bytes',
+    if (contentType != null) HttpHeaders.contentTypeHeader: contentType,
   };
 
-  final contentType = await getContentType();
-  final length = await file.length();
+  return _fileRangeResponse(request, file, headers) ??
+      Response.ok(
+        request.method == 'HEAD' ? null : file.openRead(),
+        headers: headers..[HttpHeaders.contentLengthHeader] = '${stat.size}',
+      );
+}
+
+/// Serves a range of [file], if [request] is valid 'bytes' range request.
+///
+/// If the request does not specify a range, specifies a range of the wrong
+/// type, or has a syntactic error the range is ignored and `null` is returned.
+///
+/// If the range request is valid but the file is not long enough to include the
+/// start of the range a range not satisfiable response is returned.
+///
+/// Ranges that end past the end of the file are truncated.
+Response? _fileRangeResponse(
+    Request request, File file, Map<String, Object> headers) {
   final range = request.headers[HttpHeaders.rangeHeader];
-  if (contentType != null) headers[HttpHeaders.contentTypeHeader] = contentType;
-  if (range != null) {
-    // We only support one range, where the standard support several.
-    final matches = RegExp(r'^bytes=(\d*)\-(\d*)$').firstMatch(range);
-    // If the range header have the right format, handle it.
-    if (matches != null) {
-      final startMatch = matches[1]!;
-      final endMatch = matches[2]!;
-      if (startMatch.isNotEmpty || endMatch.isNotEmpty) {
-        // Serve sub-range.
-        int start; // First byte position - inclusive.
-        int end; // Last byte position - inclusive.
-        if (startMatch.isEmpty) {
-          start = length - int.parse(endMatch);
-          if (start < 0) start = 0;
-          end = length - 1;
-        } else {
-          start = int.parse(startMatch);
-          end = endMatch.isEmpty ? length - 1 : int.parse(endMatch);
-        }
-        // If the range is syntactically invalid the Range header
-        // MUST be ignored (RFC 2616 section 14.35.1).
-        if (start <= end) {
-          if (end >= length) {
-            end = length - 1;
-          }
-          if (start >= length) {
-            return Response(
-              HttpStatus.requestedRangeNotSatisfiable,
-              headers: headers,
-            );
-          }
+  if (range == null) return null;
+  final matches = RegExp(r'^bytes=(\d*)\-(\d*)$').firstMatch(range);
+  // Ignore ranges other than bytes
+  if (matches == null) return null;
 
-          // Override Content-Length with the actual bytes sent.
-          headers[HttpHeaders.contentLengthHeader] =
-              (end - start + 1).toString();
+  final actualLength = file.lengthSync();
+  final startMatch = matches[1]!;
+  final endMatch = matches[2]!;
+  if (startMatch.isEmpty && endMatch.isEmpty) return null;
 
-          // Set 'Partial Content' status code.
-          headers[HttpHeaders.contentRangeHeader] = 'bytes $start-$end/$length';
-          // Pipe the 'range' of the file.
-          if (request.method == 'HEAD') {
-            return Response(
-              HttpStatus.partialContent,
-              headers: headers,
-            );
-          } else {
-            return Response(
-              HttpStatus.partialContent,
-              body: file.openRead(start, end + 1),
-              headers: headers,
-            );
-          }
-        }
-      }
-    }
+  int start; // First byte position - inclusive.
+  int end; // Last byte position - inclusive.
+  if (startMatch.isEmpty) {
+    start = actualLength - int.parse(endMatch);
+    if (start < 0) start = 0;
+    end = actualLength - 1;
+  } else {
+    start = int.parse(startMatch);
+    end = endMatch.isEmpty ? actualLength - 1 : int.parse(endMatch);
   }
-  headers[HttpHeaders.contentLengthHeader] = stat.size.toString();
 
-  return Response.ok(
-    request.method == 'HEAD' ? null : file.openRead(),
-    headers: headers,
+  // If the range is syntactically invalid the Range header
+  // MUST be ignored (RFC 2616 section 14.35.1).
+  if (start > end) return null;
+
+  if (end >= actualLength) {
+    end = actualLength - 1;
+  }
+  if (start >= actualLength) {
+    return Response(
+      HttpStatus.requestedRangeNotSatisfiable,
+      headers: headers,
+    );
+  }
+  return Response(
+    HttpStatus.partialContent,
+    body: request.method == 'HEAD' ? null : file.openRead(start, end + 1),
+    headers: headers
+      ..[HttpHeaders.contentLengthHeader] = (end - start + 1).toString()
+      ..[HttpHeaders.contentRangeHeader] = 'bytes $start-$end/$actualLength',
   );
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index 824b2d6..08d08dc 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: shelf_static
-version: 1.1.0
+version: 1.1.1-dev
 description: Static file server support for the shelf package and ecosystem
 repository: https://github.com/dart-lang/shelf_static
 
diff --git a/test/create_file_handler_test.dart b/test/create_file_handler_test.dart
index 907e3b4..91c4c86 100644
--- a/test/create_file_handler_test.dart
+++ b/test/create_file_handler_test.dart
@@ -90,6 +90,7 @@
       );
       expect(response.headers, containsPair('content-length', '5'));
     });
+
     test('at the end of has overflow from 0 to 9', () async {
       final handler = createFileHandler(p.join(d.sandbox, 'file.txt'));
       final response = await makeRequest(
@@ -111,6 +112,7 @@
       );
       expect(response.headers, containsPair('content-length', '8'));
     });
+
     test('at the start of has overflow from 8 to 9', () async {
       final handler = createFileHandler(p.join(d.sandbox, 'file.txt'));
       final response = await makeRequest(
@@ -128,6 +130,54 @@
         HttpStatus.requestedRangeNotSatisfiable,
       );
     });
+
+    test('ignores invalid request with start > end', () async {
+      final handler = createFileHandler(p.join(d.sandbox, 'file.txt'));
+      final response = await makeRequest(
+        handler,
+        '/file.txt',
+        headers: {'range': 'bytes=2-1'},
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.contentLength, equals(8));
+      expect(response.readAsString(), completion(equals('contents')));
+    });
+
+    test('ignores request with start > end', () async {
+      final handler = createFileHandler(p.join(d.sandbox, 'file.txt'));
+      final response = await makeRequest(
+        handler,
+        '/file.txt',
+        headers: {'range': 'bytes=2-1'},
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.contentLength, equals(8));
+      expect(response.readAsString(), completion(equals('contents')));
+    });
+
+    test('ignores request with units other than bytes', () async {
+      final handler = createFileHandler(p.join(d.sandbox, 'file.txt'));
+      final response = await makeRequest(
+        handler,
+        '/file.txt',
+        headers: {'range': 'not-bytes=0-1'},
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.contentLength, equals(8));
+      expect(response.readAsString(), completion(equals('contents')));
+    });
+
+    test('ignores request with no start or end', () async {
+      final handler = createFileHandler(p.join(d.sandbox, 'file.txt'));
+      final response = await makeRequest(
+        handler,
+        '/file.txt',
+        headers: {'range': 'bytes=-'},
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.contentLength, equals(8));
+      expect(response.readAsString(), completion(equals('contents')));
+    });
   });
 
   group('throws an ArgumentError for', () {