Introduce `routeNotFound` sentinel for shelf_router 1.1.0 (#141)

diff --git a/pkgs/shelf_router/CHANGELOG.md b/pkgs/shelf_router/CHANGELOG.md
index 1c9c9dd..0c7e1ac 100644
--- a/pkgs/shelf_router/CHANGELOG.md
+++ b/pkgs/shelf_router/CHANGELOG.md
@@ -1,3 +1,25 @@
+## v1.1.0
+ * `params` is deprecated in favor of `Request.params` adding using an extension
+   on `Request`.
+ * The default `notFoundHandler` now returns a sentinel `routeNotFound` response
+   object which causes 404 with the message 'Route not found'.
+ * __Minor breaking__: Handlers and sub-routers that return the sentinel
+   `routeNotFound` response object will be ignored and pattern matching will
+   continue on additional routes/handlers.
+
+Changing the router to continue pattern matching additional routes if a matched
+_handler_ or _nested router_ returns the sentinel `routeNotFound` response
+object is technically a _breaking change_. However, it only affects scenarios
+where the request matches a _mounted sub-router_, but does not match any route
+on this sub-router. In this case, `shelf_router` version `1.0.0` would
+immediately respond 404, without attempting to match further routes. With this
+release, the behavior changes to matching additional routes until one returns
+a custom 404 response object, or all routes have been matched.
+
+This behavior is more in line with how `shelf_router` version `0.7.x` worked,
+and since many affected users consider the behavior from `1.0.0` a defect,
+we decided to remedy the situation.
+
 ## v1.0.0
 
  * Migrate package to null-safety
diff --git a/pkgs/shelf_router/lib/src/router.dart b/pkgs/shelf_router/lib/src/router.dart
index 23eddab..5ddcd40 100644
--- a/pkgs/shelf_router/lib/src/router.dart
+++ b/pkgs/shelf_router/lib/src/router.dart
@@ -12,24 +12,50 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import 'dart:collection' show UnmodifiableMapView;
+
 import 'package:http_methods/http_methods.dart';
 import 'package:meta/meta.dart' show sealed;
 import 'package:shelf/shelf.dart';
 import 'package:shelf_router/src/router_entry.dart' show RouterEntry;
 
 /// Get a URL parameter captured by the [Router].
+@Deprecated('Use Request.params instead')
 String params(Request request, String name) {
-  final p = request.context['shelf_router/params'];
-  if (!(p is Map<String, String>)) {
-    throw Exception('no such parameter $name');
-  }
-  final value = p[name];
+  final value = request.params[name];
   if (value == null) {
     throw Exception('no such parameter $name');
   }
   return value;
 }
 
+final _emptyParams = UnmodifiableMapView(<String, String>{});
+
+extension RouterParams on Request {
+  /// Get URL parameters captured by the [Router].
+  ///
+  /// **Example**
+  /// ```dart
+  /// final app = Router();
+  ///
+  /// app.get('/hello/<name>', (Request request) {
+  ///   final name = request.params['name'];
+  ///   return Response.ok('Hello $name');
+  /// });
+  /// ```
+  ///
+  /// If no parameters are captured this returns an empty map.
+  ///
+  /// The returned map is unmodifiable.
+  Map<String, String> get params {
+    final p = context['shelf_router/params'];
+    if (p is Map<String, String>) {
+      return UnmodifiableMapView(p);
+    }
+    return _emptyParams;
+  }
+}
+
 /// Middleware to remove body from request.
 final _removeBody = createMiddleware(responseHandler: (r) {
   if (r.headers.containsKey('content-length')) {
@@ -51,7 +77,7 @@
 /// // Route pattern parameters can be specified <paramName>
 /// app.get('/users/<userName>/whoami', (Request request) async {
 ///   // The matched values can be read with params(request, param)
-///   var userName = params(request, 'userName');
+///   var userName = request.params['userName'];
 ///   return Response.ok('You are ${userName}');
 /// });
 ///
@@ -59,7 +85,8 @@
 /// // doesn't implement Handler, it's assumed to take all parameters in the
 /// // order they appear in the route pattern.
 /// app.get('/users/<userName>/say-hello', (Request request, String userName) async {
-///   return Response.ok('Hello ${uName}');
+///   assert(userName == request.params['userName']);
+///   return Response.ok('Hello ${userName}');
 /// });
 ///
 /// // It is possible to have multiple parameters, and if desired a custom
@@ -67,7 +94,7 @@
 /// // REGEXP is a regular expression (leaving out ^ and $).
 /// // If no regular expression is specified `[^/]+` will be used.
 /// app.get('/users/<userName>/messages/<msgId|\d+>', (Request request) async {
-///   var msgId = int.parse(params(request, 'msgId'));
+///   var msgId = int.parse(request.params['msgId']!);
 ///   return Response.ok(message.getById(msgId));
 /// });
 ///
@@ -144,7 +171,10 @@
       }
       var params = route.match('/' + request.url.path);
       if (params != null) {
-        return await route.invoke(request, params);
+        final response = await route.invoke(request, params);
+        if (response != routeNotFound) {
+          return response;
+        }
       }
     }
     return _notFoundHandler(request);
@@ -184,7 +214,58 @@
   /// Handle `PATCH` request to [route] using [handler].
   void patch(String route, Function handler) => add('PATCH', route, handler);
 
-  static Response _defaultNotFound(Request request) {
-    return Response.notFound('Not Found');
-  }
+  static Response _defaultNotFound(Request request) => routeNotFound;
+
+  /// Sentinel [Response] object indicating that no matching route was found.
+  ///
+  /// This is the default response value from a [Router] created without a
+  /// `notFoundHandler`, when no routes matches the incoming request.
+  ///
+  /// If the [routeNotFound] object is returned from a [Handler] the [Router]
+  /// will consider the route _not matched_, and attempt to match other routes.
+  /// This is useful when mounting nested routers, or when matching a route
+  /// is conditioned on properties beyond the path of the URL.
+  ///
+  /// **Example**
+  /// ```dart
+  /// final app = Router();
+  ///
+  /// // The pattern for this route will match '/search' and '/search?q=...',
+  /// // but if request does not have `?q=...', then the handler will return
+  /// // [Router.routeNotFound] causing the router to attempt further routes.
+  /// app.get('/search', (Request request) async {
+  ///   if (!request.uri.queryParameters.containsKey('q')) {
+  ///     return Router.routeNotFound;
+  ///   }
+  ///   return Response.ok('TODO: make search results');
+  /// });
+  ///
+  /// // Same pattern as above
+  /// app.get('/search', (Request request) async {
+  ///   return Response.ok('TODO: return search form');
+  /// });
+  ///
+  /// // Create a single nested router we can mount for handling API requests.
+  /// final api = Router();
+  ///
+  /// api.get('/version', (Request request) => Response.ok('1'));
+  ///
+  /// // Mounting router under '/api'
+  /// app.mount('/api', api);
+  ///
+  /// // If a request matches `/api/...` then the routes in the [api] router
+  /// // will be attempted. However, for a request like `/api/hello` there is
+  /// // no matching route in the [api] router. Thus, the router will return
+  /// // [Router.routeNotFound], which will cause matching to continue.
+  /// // Hence, the catch-all route below will be matched, causing a custom 404
+  /// // response with message 'nothing found'.
+  ///
+  /// // In the pattern below `<anything|.*>` is on the form `<name|regex>`,
+  /// // thus, this simply creates a URL parameter called `anything` which
+  /// // matches anything.
+  /// app.all('/<anything|.*>', (Request request) {
+  ///   return Response.notFound('nothing found');
+  /// });
+  /// ```
+  static final Response routeNotFound = Response.notFound('Route not found');
 }
diff --git a/pkgs/shelf_router/pubspec.yaml b/pkgs/shelf_router/pubspec.yaml
index 03f64df..f46854f 100644
--- a/pkgs/shelf_router/pubspec.yaml
+++ b/pkgs/shelf_router/pubspec.yaml
@@ -1,5 +1,5 @@
 name: shelf_router
-version: 1.0.0
+version: 1.1.0
 description: |
   A convinent request router for the shelf web-framework, with support for
   URL-parameters, nested routers and routers generated from source annotations.
diff --git a/pkgs/shelf_router/test/router_test.dart b/pkgs/shelf_router/test/router_test.dart
index 01ca321..a05c292 100644
--- a/pkgs/shelf_router/test/router_test.dart
+++ b/pkgs/shelf_router/test/router_test.dart
@@ -74,8 +74,8 @@
     var app = Router();
 
     app.get(r'/user/<user>/groups/<group|\d+>', (Request request) {
-      final user = params(request, 'user');
-      final group = params(request, 'group');
+      final user = request.params['user'];
+      final group = request.params['group'];
       return Response.ok('$user / $group');
     });
 
@@ -111,14 +111,14 @@
     app.mount('/api/', api);
 
     app.all('/<_|[^]*>', (Request request) {
-      return Response.notFound('catch-all-handler');
+      return Response.ok('catch-all-handler');
     });
 
     server.mount(app);
 
     expect(await get('/hello'), 'hello-world');
     expect(await get('/api/user/jonasfj/info'), 'Hello jonasfj');
-    expect(get('/api/user/jonasfj/info-wrong'), throwsA(anything));
+    expect(await get('/api/user/jonasfj/info-wrong'), 'catch-all-handler');
   });
 
   test('mount(Handler) with middleware', () async {