Overhaul the semantics of Request.handlerPath and Request.url.

The new semantics are easier to understand and make it easier to write handlers.
For full details, see the changelog.

Request.handlerPath used to be named Request.scriptName; the new name reflects
its semantics much better.

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//966063003
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c95a75..be49502 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,31 @@
+## 0.6.0
+
+**Breaking change:** The semantics of `Request.scriptName` and
+[`Request.url`][url] have been overhauled, and the former has been renamed to
+[`Request.handlerPath`][handlerPath]. `handlerPath` is now the root-relative URL
+path to the current handler, while `url`'s path is the relative path from the
+current handler to the requested. The new semantics are easier to describe and
+to understand.
+
+[url]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf.Request@id_url
+[handlerPath]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf.Request@id_handlerPath
+
+Practically speaking, the main difference is that the `/` at the beginning of
+`url`'s path has been moved to the end of `handlerPath`. This makes `url`'s path
+easier to parse using the `path` package.
+
+[`Request.change`][change]'s handling of `handlerPath` and `url` has also
+changed. Instead of taking both parameters separately and requiring that the
+user manually maintain all the associated guarantees, it now takes a single
+`path` parameter. This parameter is the relative path from the current
+`handlerPath` to the next one, and sets both `handlerPath` and `url` on the new
+`Request` accordingly.
+
+[change]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf.Request@id_change
+
 ## 0.5.7
 
-* Updated `Request` to support the `body` model from `Response`.  
+* Updated `Request` to support the `body` model from `Response`.
 
 ## 0.5.6
 
diff --git a/README.md b/README.md
index b0fcbf6..4894a31 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,27 @@
 call based on the request's URI or HTTP method, while a cascading middleware
 might call each one in sequence until one returns a successful response.
 
+Middleware that routes requests between handlers should be sure to update each
+request's [`handlerPath`][handlerPath] and [`url`][url]. This allows inner
+handlers to know where they are in the application so they can do their own
+routing correctly. This can be easily accomplished using
+[`Request.change()`][change]:
+
+[handlerPath]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf.Request@id_handlerPath
+[url]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf.Request@id_url
+[change]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf.Request@id_change
+
+```dart
+// In an imaginary routing middleware...
+var component = request.url.pathComponents.first;
+var handler = _handlers[component];
+if (handler == null) return new Response.notFound(null);
+
+// Create a new request just like this one but with whatever URL comes after
+// [component] instead.
+return handler(request.change(script: component));
+```
+
 ## Adapters
 
 An adapter is any code that creates [shelf.Request][] objects, passes them to a
@@ -78,7 +99,7 @@
 [shelf_io.serve]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf-io@id_serve
 
 When implementing an adapter, some rules must be followed. The adapter must not
-pass the `url` or `scriptName` parameters to [new shelf.Request][]; it should
+pass the `url` or `handlerPath` parameters to [new shelf.Request][]; it should
 only pass `requestedUri`. If it passes the `context` parameter, all keys must
 begin with the adapter's package name followed by a period. If multiple headers
 with the same name are received, the adapter must collapse them into a single
diff --git a/lib/src/handlers/logger.dart b/lib/src/handlers/logger.dart
index f0833d7..eaac75c 100644
--- a/lib/src/handlers/logger.dart
+++ b/lib/src/handlers/logger.dart
@@ -31,8 +31,8 @@
     var watch = new Stopwatch()..start();
 
     return syncFuture(() => innerHandler(request)).then((response) {
-      var msg = _getMessage(startTime, response.statusCode, request.url,
-          request.method, watch.elapsed);
+      var msg = _getMessage(startTime, response.statusCode,
+          request.requestedUri, request.method, watch.elapsed);
 
       logger(msg, false);
 
@@ -40,8 +40,8 @@
     }, onError: (error, stackTrace) {
       if (error is HijackException) throw error;
 
-      var msg = _getErrorMessage(startTime, request.url, request.method,
-          watch.elapsed, error, stackTrace);
+      var msg = _getErrorMessage(startTime, request.requestedUri,
+          request.method, watch.elapsed, error, stackTrace);
 
       logger(msg, true);
 
@@ -50,12 +50,13 @@
   };
 };
 
-String _getMessage(DateTime requestTime, int statusCode, Uri url, String method,
-    Duration elapsedTime) {
-  return '${requestTime}\t$elapsedTime\t$method\t[${statusCode}]\t${url}';
+String _getMessage(DateTime requestTime, int statusCode, Uri requestedUri,
+    String method, Duration elapsedTime) {
+  return '${requestTime}\t$elapsedTime\t$method\t[${statusCode}]\t'
+      '${requestedUri.path}${requestedUri.query}';
 }
 
-String _getErrorMessage(DateTime requestTime, Uri url, String method,
+String _getErrorMessage(DateTime requestTime, Uri requestedUri, String method,
     Duration elapsedTime, Object error, StackTrace stack) {
   var chain = new Chain.current();
   if (stack != null) {
@@ -63,7 +64,8 @@
         .foldFrames((frame) => frame.isCore || frame.package == 'shelf').terse;
   }
 
-  var msg = '${requestTime}\t$elapsedTime\t$method\t${url}\n$error';
+  var msg = '${requestTime}\t$elapsedTime\t$method\t${requestedUri.path}'
+      '${requestedUri.query}\n$error';
   if (chain == null) return msg;
 
   return '$msg\n$chain';
diff --git a/lib/src/request.dart b/lib/src/request.dart
index 253a516..85e4656 100644
--- a/lib/src/request.dart
+++ b/lib/src/request.dart
@@ -23,32 +23,37 @@
 
 /// Represents an HTTP request to be processed by a Shelf application.
 class Request extends Message {
-  /// The remainder of the [requestedUri] path and query designating the virtual
-  /// "location" of the request's target within the handler.
+  /// The URL path from the current handler to the requested resource, relative
+  /// to [handlerPath], plus any query parameters.
   ///
-  /// [url] may be an empty, if [requestedUri]targets the handler
-  /// root and does not have a trailing slash.
+  /// This should be used by handlers for determining which resource to serve,
+  /// in preference to [requestedUri]. This allows handlers to do the right
+  /// thing when they're mounted anywhere in the application. Routers should be
+  /// sure to update this when dispatching to a nested handler, using the
+  /// `path` parameter to [change].
   ///
-  /// [url] is never null. If it is not empty, it will start with `/`.
+  /// [url]'s path is always relative. It may be empty, if [requestedUri] ends
+  /// at this handler. [url] will always have the same query parameters as
+  /// [requestedUri].
   ///
-  /// [scriptName] and [url] combine to create a valid path that should
-  /// correspond to the [requestedUri] path.
+  /// [handlerPath] and [url]'s path combine to create [requestedUri]'s path.
   final Uri url;
 
   /// The HTTP request method, such as "GET" or "POST".
   final String method;
 
-  /// The initial portion of the [requestedUri] path that corresponds to the
-  /// handler.
+  /// The URL path to the current handler.
   ///
-  /// [scriptName] allows a handler to know its virtual "location".
+  /// This allows a handler to know its location within the URL-space of an
+  /// application. Routers should be sure to update this when dispatching to a
+  /// nested handler, using the `path` parameter to [change].
   ///
-  /// If the handler corresponds to the "root" of a server, it will be an
-  /// empty string, otherwise it will start with a `/`
+  /// [handlerPath] is always a root-relative URL path; that is, it always
+  /// starts with `/`. It will also end with `/` whenever [url]'s path is
+  /// non-empty, or if [requestUri]'s path ends with `/`.
   ///
-  /// [scriptName] and [url] combine to create a valid path that should
-  /// correspond to the [requestedUri] path.
-  final String scriptName;
+  /// [handlerPath] and [url]'s path combine to create [requestedUri]'s path.
+  final String handlerPath;
 
   /// The HTTP protocol version used in the request, either "1.0" or "1.1".
   final String protocolVersion;
@@ -83,11 +88,12 @@
 
   /// Creates a new [Request].
   ///
-  /// If [url] and [scriptName] are omitted, they are inferred from
-  /// [requestedUri].
-  ///
-  /// Setting one of [url] or [scriptName] and not the other will throw an
-  /// [ArgumentError].
+  /// [handlerPath] must be root-relative. [url]'s path must be fully relative,
+  /// and it must have the same query parameters as [requestedUri].
+  /// [handlerPath] and [url]'s path must combine to be the path component of
+  /// [requestedUri]. If they're not passed, [handlerPath] will default to `/`
+  /// and [url] to `requestedUri.path` without the initial `/`. If only one is
+  /// passed, the other will be inferred.
   ///
   /// [body] is the request body. It may be either a [String], a
   /// [Stream<List<int>>], or `null` to indicate no body.
@@ -127,14 +133,14 @@
   /// See also [hijack].
   // TODO(kevmoo) finish documenting the rest of the arguments.
   Request(String method, Uri requestedUri, {String protocolVersion,
-      Map<String, String> headers, Uri url, String scriptName, body,
+      Map<String, String> headers, String handlerPath, Uri url, body,
       Encoding encoding, Map<String, Object> context,
       OnHijackCallback onHijack})
       : this._(method, requestedUri,
           protocolVersion: protocolVersion,
           headers: headers,
           url: url,
-          scriptName: scriptName,
+          handlerPath: handlerPath,
           body: body,
           encoding: encoding,
           context: context,
@@ -147,43 +153,31 @@
   /// source [Request] to ensure that [hijack] can only be called once, even
   /// from a changed [Request].
   Request._(this.method, Uri requestedUri, {String protocolVersion,
-      Map<String, String> headers, Uri url, String scriptName, body,
+      Map<String, String> headers, String handlerPath, Uri url, body,
       Encoding encoding, Map<String, Object> context, _OnHijack onHijack})
       : this.requestedUri = requestedUri,
         this.protocolVersion = protocolVersion == null
             ? '1.1'
             : protocolVersion,
-        this.url = _computeUrl(requestedUri, url, scriptName),
-        this.scriptName = _computeScriptName(requestedUri, url, scriptName),
+        this.url = _computeUrl(requestedUri, handlerPath, url),
+        this.handlerPath = _computeHandlerPath(requestedUri, handlerPath, url),
         this._onHijack = onHijack,
         super(body, encoding: encoding, headers: headers, context: context) {
     if (method.isEmpty) throw new ArgumentError('method cannot be empty.');
 
     if (!requestedUri.isAbsolute) {
-      throw new ArgumentError('requstedUri must be an absolute URI.');
-    }
-
-    // TODO(kevmoo) if defined, check that scriptName is a fully-encoded, valid
-    // path component
-    if (this.scriptName.isNotEmpty && !this.scriptName.startsWith('/')) {
-      throw new ArgumentError('scriptName must be empty or start with "/".');
-    }
-
-    if (this.scriptName == '/') {
       throw new ArgumentError(
-          'scriptName can never be "/". It should be empty instead.');
+          'requestedUri "$requestedUri" must be an absolute URL.');
     }
 
-    if (this.scriptName.endsWith('/')) {
-      throw new ArgumentError('scriptName must not end with "/".');
+    if (requestedUri.fragment.isNotEmpty) {
+      throw new ArgumentError(
+          'requestedUri "$requestedUri" may not have a fragment.');
     }
 
-    if (this.url.path.isNotEmpty && !this.url.path.startsWith('/')) {
-      throw new ArgumentError('url must be empty or start with "/".');
-    }
-
-    if (this.scriptName.isEmpty && this.url.path.isEmpty) {
-      throw new ArgumentError('scriptName and url cannot both be empty.');
+    if (this.handlerPath + this.url.path != this.requestedUri.path) {
+      throw new ArgumentError('handlerPath "$handlerPath" and url "$url" must '
+          'combine to equal requestedUri path "${requestedUri.path}".');
     }
   }
 
@@ -191,43 +185,35 @@
   /// changes.
   ///
   /// New key-value pairs in [context] and [headers] will be added to the copied
-  /// [Request].
+  /// [Request]. If [context] or [headers] includes a key that already exists,
+  /// the key-value pair will replace the corresponding entry in the copied
+  /// [Request]. All other context and header values from the [Request] will be
+  /// included in the copied [Request] unchanged.
   ///
-  /// If [context] or [headers] includes a key that already exists, the
-  /// key-value pair will replace the corresponding entry in the copied
-  /// [Request].
+  /// [path] is used to update both [handlerPath] and [url]. It's designed for
+  /// routing middleware, and represents the path from the current handler to
+  /// the next handler. It must be a prefix of [url]; [handlerPath] becomes
+  /// `handlerPath + "/" + path`, and [url] becomes relative to that. For
+  /// example:
   ///
-  /// All other context and header values from the [Request] will be included
-  /// in the copied [Request] unchanged.
+  ///     print(request.handlerPath); // => /static/
+  ///     print(request.url);        // => dir/file.html
   ///
-  /// If [scriptName] is provided and [url] is not, [scriptName] must be a
-  /// prefix of [this.url]. [url] will default to [this.url] with this prefix
-  /// removed. Useful for routing middleware that sends requests to an inner
-  /// [Handler].
+  ///     request = request.change(path: "dir");
+  ///     print(request.handlerPath); // => /static/dir/
+  ///     print(request.url);        // => file.html
   Request change({Map<String, String> headers, Map<String, Object> context,
-      String scriptName, Uri url}) {
+      String path}) {
     headers = updateMap(this.headers, headers);
     context = updateMap(this.context, context);
 
-    if (scriptName != null && url == null) {
-      var path = this.url.path;
-      if (path.startsWith(scriptName)) {
-        path = path.substring(scriptName.length);
-        url = new Uri(path: path, query: this.url.query);
-      } else {
-        throw new ArgumentError('If scriptName is provided without url, it must'
-            ' be a prefix of the existing url path.');
-      }
-    }
-
-    if (url == null) url = this.url;
-    if (scriptName == null) scriptName = this.scriptName;
+    var handlerPath = this.handlerPath;
+    if (path != null) handlerPath += path;
 
     return new Request._(this.method, this.requestedUri,
         protocolVersion: this.protocolVersion,
         headers: headers,
-        url: url,
-        scriptName: scriptName,
+        handlerPath: handlerPath,
         body: this.read(),
         context: context,
         onHijack: _onHijack);
@@ -282,41 +268,80 @@
 
 /// Computes `url` from the provided [Request] constructor arguments.
 ///
-/// If [url] and [scriptName] are `null`, infer value from [requestedUrl],
-/// otherwise return [url].
-///
-/// If [url] is provided, but [scriptName] is omitted, throws an
-/// [ArgumentError].
-Uri _computeUrl(Uri requestedUri, Uri url, String scriptName) {
-  if (url == null && scriptName == null) {
-    return new Uri(path: requestedUri.path, query: requestedUri.query);
+/// If [url] is `null`, the value is inferred from [requestedUrl] and
+/// [handlerPath] if available. Otherwise [url] is returned.
+Uri _computeUrl(Uri requestedUri, String handlerPath, Uri url) {
+  if (handlerPath != null &&
+      handlerPath != requestedUri.path &&
+      !handlerPath.endsWith("/")) {
+    handlerPath += "/";
   }
 
-  if (url != null && scriptName != null) {
-    if (url.scheme.isNotEmpty) throw new ArgumentError('url must be relative.');
+  if (url != null) {
+    if (url.scheme.isNotEmpty || url.hasAuthority || url.fragment.isNotEmpty) {
+      throw new ArgumentError('url "$url" may contain only a path and query '
+          'parameters.');
+    }
+
+    if (!requestedUri.path.endsWith(url.path)) {
+      throw new ArgumentError('url "$url" must be a suffix of requestedUri '
+          '"$requestedUri".');
+    }
+
+    if (requestedUri.query != url.query) {
+      throw new ArgumentError('url "$url" must have the same query parameters '
+          'as requestedUri "$requestedUri".');
+    }
+
+    if (url.path.startsWith('/')) {
+      throw new ArgumentError('url "$url" must be relative.');
+    }
+
+    var startOfUrl = requestedUri.path.length - url.path.length;
+    if (requestedUri.path.substring(startOfUrl - 1, startOfUrl) != '/') {
+      throw new ArgumentError('url "$url" must be on a path boundary in '
+          'requestedUri "$requestedUri".');
+    }
+
     return url;
+  } else if (handlerPath != null) {
+    return new Uri(
+        path: requestedUri.path.substring(handlerPath.length),
+        query: requestedUri.query);
+  } else {
+    // Skip the initial "/".
+    var path = requestedUri.path.substring(1);
+    return new Uri(path: path, query: requestedUri.query);
   }
-
-  throw new ArgumentError(
-      'url and scriptName must both be null or both be set.');
 }
 
-/// Computes `scriptName` from the provided [Request] constructor arguments.
+/// Computes `handlerPath` from the provided [Request] constructor arguments.
 ///
-/// If [url] and [scriptName] are `null` it returns an empty string, otherwise
-/// [scriptName] is returned.
-///
-/// If [script] is provided, but [url] is omitted, throws an
-/// [ArgumentError].
-String _computeScriptName(Uri requstedUri, Uri url, String scriptName) {
-  if (url == null && scriptName == null) {
-    return '';
+/// If [handlerPath] is `null`, the value is inferred from [requestedUrl] and
+/// [url] if available. Otherwise [handlerPath] is returned.
+String _computeHandlerPath(Uri requestedUri, String handlerPath, Uri url) {
+  if (handlerPath != null &&
+      handlerPath != requestedUri.path &&
+      !handlerPath.endsWith("/")) {
+    handlerPath += "/";
   }
 
-  if (url != null && scriptName != null) {
-    return scriptName;
-  }
+  if (handlerPath != null) {
+    if (!requestedUri.path.startsWith(handlerPath)) {
+      throw new ArgumentError('handlerPath "$handlerPath" must be a prefix of '
+          'requestedUri path "${requestedUri.path}"');
+    }
 
-  throw new ArgumentError(
-      'url and scriptName must both be null or both be set.');
+    if (!handlerPath.startsWith('/')) {
+      throw new ArgumentError(
+          'handlerPath "$handlerPath" must be root-relative.');
+    }
+
+    return handlerPath;
+  } else if (url != null) {
+    var index = requestedUri.path.indexOf(url.path);
+    return requestedUri.path.substring(0, index);
+  } else {
+    return '/';
+  }
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index 2dc368a..7fbdf91 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: shelf
-version: 0.5.8-dev
+version: 0.6.0
 author: Dart Team <misc@dartlang.org>
 description: Web Server Middleware for Dart
 homepage: https://github.com/dart-lang/shelf
diff --git a/test/log_middleware_test.dart b/test/log_middleware_test.dart
index e056504..efbda5b 100644
--- a/test/log_middleware_test.dart
+++ b/test/log_middleware_test.dart
@@ -44,7 +44,7 @@
     });
   });
 
-  test('logs a request with an asynchronous response', () {
+  test('logs a request with an asynchronous error response', () {
     var handler = const Pipeline()
         .addMiddleware(logRequests(logger: (msg, isError) {
       expect(gotLog, isFalse);
diff --git a/test/request_test.dart b/test/request_test.dart
index fa75bd0..4afeff4 100644
--- a/test/request_test.dart
+++ b/test/request_test.dart
@@ -29,94 +29,145 @@
       expect(request.protocolVersion, '1.0');
     });
 
-    test('requestedUri must be absolute', () {
-      expect(() => new Request('GET', Uri.parse('/path')), throwsArgumentError);
+    group("url", () {
+      test("defaults to the requestedUri's relativized path and query", () {
+        var request =
+            new Request('GET', Uri.parse("http://localhost/foo/bar?q=1"));
+        expect(request.url, equals(Uri.parse("foo/bar?q=1")));
+      });
+
+      test("is inferred from handlerPath if possible", () {
+        var request = new Request(
+            'GET', Uri.parse("http://localhost/foo/bar?q=1"),
+            handlerPath: '/foo/');
+        expect(request.url, equals(Uri.parse("bar?q=1")));
+      });
+
+      test("uses the given value if passed", () {
+        var request = new Request(
+            'GET', Uri.parse("http://localhost/foo/bar?q=1"),
+            url: Uri.parse("bar?q=1"));
+        expect(request.url, equals(Uri.parse("bar?q=1")));
+      });
     });
 
-    test('if uri is null, scriptName must be null', () {
-      expect(() => new Request('GET', Uri.parse('/path'),
-          scriptName: '/script/name'), throwsArgumentError);
+    group("handlerPath", () {
+      test("defaults to '/'", () {
+        var request = new Request('GET', Uri.parse("http://localhost/foo/bar"));
+        expect(request.handlerPath, equals('/'));
+      });
+
+      test("is inferred from url if possible", () {
+        var request = new Request(
+            'GET', Uri.parse("http://localhost/foo/bar?q=1"),
+            url: Uri.parse("bar?q=1"));
+        expect(request.handlerPath, equals("/foo/"));
+      });
+
+      test("uses the given value if passed", () {
+        var request = new Request(
+            'GET', Uri.parse("http://localhost/foo/bar?q=1"),
+            handlerPath: '/foo/');
+        expect(request.handlerPath, equals("/foo/"));
+      });
+
+      test("adds a trailing slash to the given value if necessary", () {
+        var request = new Request(
+            'GET', Uri.parse("http://localhost/foo/bar?q=1"),
+            handlerPath: '/foo');
+        expect(request.handlerPath, equals("/foo/"));
+        expect(request.url, equals(Uri.parse("bar?q=1")));
+      });
     });
 
-    test('if scriptName is null, uri must be null', () {
-      var relativeUri = new Uri(path: '/cool/beans.html');
-      expect(() => new Request('GET', Uri.parse('/path'), url: relativeUri),
-          throwsArgumentError);
-    });
+    group("errors", () {
+      group('requestedUri', () {
+        test('must be absolute', () {
+          expect(() => new Request('GET', Uri.parse('/path')),
+              throwsArgumentError);
+        });
 
-    test('uri must be relative', () {
-      var relativeUri = Uri.parse('http://localhost/test');
+        test('may not have a fragment', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/#fragment'));
+          }, throwsArgumentError);
+        });
+      });
 
-      expect(() => new Request('GET', LOCALHOST_URI,
-          url: relativeUri, scriptName: '/news'), throwsArgumentError);
+      group('url', () {
+        test('must be relative', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/test'),
+                url: Uri.parse('http://localhost/test'));
+          }, throwsArgumentError);
+        });
 
-      // NOTE: explicitly testing fragments due to Issue 18053
-      relativeUri = Uri.parse('http://localhost/test#fragment');
+        test('may not be root-relative', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/test'),
+                url: Uri.parse('/test'));
+          }, throwsArgumentError);
+        });
 
-      expect(() => new Request('GET', LOCALHOST_URI,
-          url: relativeUri, scriptName: '/news'), throwsArgumentError);
-    });
+        test('may not have a fragment', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/test'),
+                url: Uri.parse('test#fragment'));
+          }, throwsArgumentError);
+        });
 
-    test('uri and scriptName', () {
-      var pathInfo = '/pages/page.html?utm_source=ABC123';
-      var scriptName = '/assets/static';
-      var fullUrl = 'http://localhost/other_path/other_resource.asp';
-      var request = new Request('GET', Uri.parse(fullUrl),
-          url: Uri.parse(pathInfo), scriptName: scriptName);
+        test('must be a suffix of requestedUri', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/dir/test'),
+                url: Uri.parse('dir'));
+          }, throwsArgumentError);
+        });
 
-      expect(request.scriptName, scriptName);
-      expect(request.url.path, '/pages/page.html');
-      expect(request.url.query, 'utm_source=ABC123');
-    });
+        test('must have the same query parameters as requestedUri', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/test?q=1&r=2'),
+                url: Uri.parse('test?q=2&r=1'));
+          }, throwsArgumentError);
 
-    test('minimal uri', () {
-      var pathInfo = '/';
-      var scriptName = '/assets/static';
-      var fullUrl = 'http://localhost$scriptName$pathInfo';
-      var request = new Request('GET', Uri.parse(fullUrl),
-          url: Uri.parse(pathInfo), scriptName: scriptName);
+          // Order matters for query parameters.
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/test?q=1&r=2'),
+                url: Uri.parse('test?r=2&q=1'));
+          }, throwsArgumentError);
+        });
+      });
 
-      expect(request.scriptName, scriptName);
-      expect(request.url.path, '/');
-      expect(request.url.query, '');
-    });
+      group('handlerPath', () {
+        test('must be a prefix of requestedUri', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/dir/test'),
+                handlerPath: '/test');
+          }, throwsArgumentError);
+        });
 
-    test('invalid url', () {
-      var testUrl = 'page';
-      var scriptName = '/assets/static';
-      var fullUrl = 'http://localhost$scriptName$testUrl';
+        test('must start with "/"', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/test'),
+                handlerPath: 'test');
+          }, throwsArgumentError);
+        });
+      });
 
-      expect(() => new Request('GET', Uri.parse(fullUrl),
-              url: Uri.parse(testUrl), scriptName: scriptName),
-          throwsArgumentError);
-    });
+      group('handlerPath + url must', () {
+        test('be requestedUrl path', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/foo/bar/baz'),
+                handlerPath: '/foo/', url: Uri.parse('baz'));
+          }, throwsArgumentError);
+        });
 
-    test('scriptName with no leading slash', () {
-      var pathInfo = '/page';
-      var scriptName = 'assets/static';
-      var fullUrl = 'http://localhost/assets/static/pages';
-
-      expect(() => new Request('GET', Uri.parse(fullUrl),
-              url: Uri.parse(pathInfo), scriptName: scriptName),
-          throwsArgumentError);
-
-      pathInfo = '/assets/static/page';
-      scriptName = '/';
-      fullUrl = 'http://localhost/assets/static/pages';
-
-      expect(() => new Request('GET', Uri.parse(fullUrl),
-              url: Uri.parse(pathInfo), scriptName: scriptName),
-          throwsArgumentError);
-    });
-
-    test('scriptName that is only a slash', () {
-      var pathInfo = '/assets/static/page';
-      var scriptName = '/';
-      var fullUrl = 'http://localhost/assets/static/pages';
-
-      expect(() => new Request('GET', Uri.parse(fullUrl),
-              url: Uri.parse(pathInfo), scriptName: scriptName),
-          throwsArgumentError);
+        test('be on a path boundary', () {
+          expect(() {
+            new Request('GET', Uri.parse('http://localhost/foo/bar/baz'),
+                handlerPath: '/foo/ba', url: Uri.parse('r/baz'));
+          }, throwsArgumentError);
+        });
+      });
     });
   });
 
@@ -143,8 +194,8 @@
       var request = new Request('GET', uri,
           protocolVersion: '2.0',
           headers: {'header1': 'header value 1'},
-          url: Uri.parse('/file.html'),
-          scriptName: '/static',
+          url: Uri.parse('file.html'),
+          handlerPath: '/static/',
           body: controller.stream,
           context: {'context1': 'context value 1'});
 
@@ -155,7 +206,7 @@
       expect(copy.protocolVersion, request.protocolVersion);
       expect(copy.headers, same(request.headers));
       expect(copy.url, request.url);
-      expect(copy.scriptName, request.scriptName);
+      expect(copy.handlerPath, request.handlerPath);
       expect(copy.context, same(request.context));
       expect(copy.readAsString(), completion('hello, world'));
 
@@ -167,41 +218,42 @@
       });
     });
 
-    group('with just scriptName', () {
-      test('updates url if scriptName matches existing url', () {
-        var uri = Uri.parse('https://test.example.com/static/file.html');
-        var request = new Request('GET', uri);
-        var copy = request.change(scriptName: '/static');
+    group('with path', () {
+      test('updates handlerPath and url', () {
+        var uri = Uri.parse('https://test.example.com/static/dir/file.html');
+        var request = new Request('GET', uri,
+            handlerPath: '/static/', url: Uri.parse('dir/file.html'));
+        var copy = request.change(path: 'dir');
 
-        expect(copy.scriptName, '/static');
-        expect(copy.url, Uri.parse('/file.html'));
+        expect(copy.handlerPath, '/static/dir/');
+        expect(copy.url, Uri.parse('file.html'));
       });
 
-      test('throws if striptName does not match existing url', () {
-        var uri = Uri.parse('https://test.example.com/static/file.html');
-        var request = new Request('GET', uri);
+      test('allows a trailing slash', () {
+        var uri = Uri.parse('https://test.example.com/static/dir/file.html');
+        var request = new Request('GET', uri,
+            handlerPath: '/static/', url: Uri.parse('dir/file.html'));
+        var copy = request.change(path: 'dir/');
 
-        expect(() => request.change(scriptName: '/nope'), throwsArgumentError);
+        expect(copy.handlerPath, '/static/dir/');
+        expect(copy.url, Uri.parse('file.html'));
       });
-    });
 
-    test('url', () {
-      var uri = Uri.parse('https://test.example.com/static/file.html');
-      var request = new Request('GET', uri);
-      var copy = request.change(url: Uri.parse('/other_path/file.html'));
+      test('throws if path does not match existing uri', () {
+        var uri = Uri.parse('https://test.example.com/static/dir/file.html');
+        var request = new Request('GET', uri,
+            handlerPath: '/static/', url: Uri.parse('dir/file.html'));
 
-      expect(copy.scriptName, '');
-      expect(copy.url, Uri.parse('/other_path/file.html'));
-    });
+        expect(() => request.change(path: 'wrong'), throwsArgumentError);
+      });
 
-    test('scriptName and url', () {
-      var uri = Uri.parse('https://test.example.com/static/file.html');
-      var request = new Request('GET', uri);
-      var copy = request.change(
-          scriptName: '/dynamic', url: Uri.parse('/other_path/file.html'));
+      test("throws if path isn't a path boundary", () {
+        var uri = Uri.parse('https://test.example.com/static/dir/file.html');
+        var request = new Request('GET', uri,
+            handlerPath: '/static/', url: Uri.parse('dir/file.html'));
 
-      expect(copy.scriptName, '/dynamic');
-      expect(copy.url, Uri.parse('/other_path/file.html'));
+        expect(() => request.change(path: 'di'), throwsArgumentError);
+      });
     });
   });
 }
diff --git a/test/shelf_io_test.dart b/test/shelf_io_test.dart
index 1a0ad78..586a961 100644
--- a/test/shelf_io_test.dart
+++ b/test/shelf_io_test.dart
@@ -85,11 +85,11 @@
       var expectedUrl = 'http://localhost:$_serverPort$path';
       expect(request.requestedUri, Uri.parse(expectedUrl));
 
-      expect(request.url.path, '/foo/bar');
+      expect(request.url.path, 'foo/bar');
       expect(request.url.pathSegments, ['foo', 'bar']);
       expect(request.protocolVersion, '1.1');
       expect(request.url.query, 'qs=value');
-      expect(request.scriptName, '');
+      expect(request.handlerPath, '/');
 
       return syncHandler(request);
     });
diff --git a/test/test_util.dart b/test/test_util.dart
index 193e322..b6db278 100644
--- a/test/test_util.dart
+++ b/test/test_util.dart
@@ -23,7 +23,7 @@
     {int statusCode, Map<String, String> headers}) {
   if (statusCode == null) statusCode = 200;
   return new Response(statusCode,
-      headers: headers, body: 'Hello from ${request.url.path}');
+      headers: headers, body: 'Hello from ${request.requestedUri.path}');
 }
 
 /// Calls [syncHandler] and wraps the response in a [Future].