diff --git a/pkgs/shelf_router/lib/src/router.dart b/pkgs/shelf_router/lib/src/router.dart index 720d3c3e..380863db 100644 --- a/pkgs/shelf_router/lib/src/router.dart +++ b/pkgs/shelf_router/lib/src/router.dart @@ -56,6 +56,40 @@ extension RouterParams on Request { } return _emptyParams; } + + /// Get URL parameters captured by the [Router.mount]. + /// They can be accessed from inside the mounted routes. + /// + /// **Example** + /// ```dart + /// Router createUsersRouter() { + /// var router = Router(); + /// + /// String getUser(Request r) => r.mountedParams['user']!; + /// + /// router.get('/self', (Request request) { + /// return Response.ok("I'm ${getUser(request)}"); + /// }); + /// + /// return router; + /// } + /// + /// var app = Router(); + /// + /// final usersRouter = createUsersRouter(); + /// app.mount('/users/', (Request r, String user) => usersRouter(r)); + /// ``` + /// + /// If no parameters are captured this returns an empty map. + /// + /// The returned map is unmodifiable. + Map get mountedParams { + final p = context['shelf_router/mountedParams']; + if (p is Map) { + return UnmodifiableMapView(p); + } + return _emptyParams; + } } /// Middleware to remove body from request. @@ -113,6 +147,12 @@ class Router { final List _routes = []; final Handler _notFoundHandler; + /// Name of the parameter used for matching the rest of te path in a mounted + /// route. + /// Prefixed with two underscores to avoid conflicts + /// with user defined path parameters + static const _kRestPathParam = '__path'; + /// Creates a new [Router] routing requests to handlers. /// /// The [notFoundHandler] will be invoked for requests where no matching route @@ -142,31 +182,100 @@ class Router { /// Handle all request to [route] using [handler]. void all(String route, Function handler) { - _routes.add(RouterEntry('ALL', route, handler)); + _all(route, handler, applyParamsOnHandle: true); + } + + void _all(String route, Function handler, + {required bool applyParamsOnHandle}) { + _routes.add(RouterEntry( + 'ALL', + route, + handler, + applyParamsOnHandle: applyParamsOnHandle, + )); } /// Mount a handler below a prefix. - /// - /// In this case prefix may not contain any parameters, nor - void mount(String prefix, Handler handler) { + void mount(String prefix, Function handler) { if (!prefix.startsWith('/')) { throw ArgumentError.value(prefix, 'prefix', 'must start with a slash'); } - // first slash is always in request.handlerPath - final path = prefix.substring(1); + const restPathParam = _kRestPathParam; + if (prefix.endsWith('/')) { - all('$prefix', (Request request) { - return handler(request.change(path: path)); - }); + _all( + '$prefix<$restPathParam|[^]*>', + (Request request, RouterEntry route) { + // Remove path param from extracted route params + final paramsList = [...route.params]..removeLast(); + return _invokeMountedHandler(request, handler, paramsList); + }, + applyParamsOnHandle: false, + ); + } else { + _all( + prefix, + (Request request, RouterEntry route) { + return _invokeMountedHandler(request, handler, route.params); + }, + applyParamsOnHandle: false, + ); + _all( + '$prefix/<$restPathParam|[^]*>', + (Request request, RouterEntry route) { + // Remove path param from extracted route params + final paramsList = [...route.params]..removeLast(); + return _invokeMountedHandler(request, handler, paramsList); + }, + applyParamsOnHandle: false, + ); + } + } + + Future _invokeMountedHandler( + Request request, Function handler, List pathParams) async { + final paramsMap = request.params; + final effectivePath = _getEffectiveMountPath(request.url.path, paramsMap); + + final modifiedRequest = request.change( + path: effectivePath, + context: { + // Include the parameters captured here as mounted parameters. + // We also include previous mounted params in case there is double + // nesting of `mount`s + 'shelf_router/mountedParams': { + ...request.mountedParams, + ...paramsMap, + }, + }, + ); + + return await Function.apply(handler, [ + modifiedRequest, + ...pathParams.map((param) => paramsMap[param]), + ]) as Response; + } + + /// Removes the "rest path" from the requested [urlPath] in mounted routes. + /// This new path is then used to update the scope of the mounted handler with + /// [Request.change] + String _getEffectiveMountPath( + String urlPath, + Map paramsMap, + ) { + final pathParamSegment = paramsMap[_kRestPathParam]; + late final String effectivePath; + if (pathParamSegment != null && pathParamSegment.isNotEmpty) { + /// If we encounter the "rest path" parameter we remove it + /// from the request path that shelf will handle. + effectivePath = + urlPath.substring(0, urlPath.length - pathParamSegment.length); } else { - all(prefix, (Request request) { - return handler(request.change(path: path)); - }); - all('$prefix/', (Request request) { - return handler(request.change(path: '$path/')); - }); + // No parameters in the requested path + effectivePath = urlPath; } + return effectivePath; } /// Route incoming requests to registered handlers. diff --git a/pkgs/shelf_router/lib/src/router_entry.dart b/pkgs/shelf_router/lib/src/router_entry.dart index e7b0bbbb..fae4e760 100644 --- a/pkgs/shelf_router/lib/src/router_entry.dart +++ b/pkgs/shelf_router/lib/src/router_entry.dart @@ -34,6 +34,14 @@ class RouterEntry { final Function _handler; final Middleware _middleware; + /// If the arguments should be applied or not to the handler function. + /// This is useful to have as false when there is + /// internal logic that registers routes and the number of expected arguments + /// by the user is unknown. i.e: [Router.mount] + /// When this is false, this [RouterEntry] is provided as an argument along + /// the [Request] so that the caller can read information from the route. + final bool _applyParamsOnHandle; + /// Expression that the request path must match. /// /// This also captures any parameters in the route pattern. @@ -46,13 +54,14 @@ class RouterEntry { List get params => _params.toList(); // exposed for using generator. RouterEntry._(this.verb, this.route, this._handler, this._middleware, - this._routePattern, this._params); + this._routePattern, this._params, this._applyParamsOnHandle); factory RouterEntry( String verb, String route, Function handler, { Middleware? middleware, + bool applyParamsOnHandle = true, }) { middleware = middleware ?? ((Handler fn) => fn); @@ -77,7 +86,14 @@ class RouterEntry { final routePattern = RegExp('^$pattern\$'); return RouterEntry._( - verb, route, handler, middleware, routePattern, params); + verb, + route, + handler, + middleware, + routePattern, + params, + applyParamsOnHandle, + ); } /// Returns a map from parameter name to value, if the path matches the @@ -102,9 +118,15 @@ class RouterEntry { request = request.change(context: {'shelf_router/params': params}); return await _middleware((request) async { + if (!_applyParamsOnHandle) { + // We handle the request just providing this route + return await _handler(request, this) as Response; + } + if (_handler is Handler || _params.isEmpty) { return await _handler(request) as Response; } + return await Function.apply(_handler, [ request, ..._params.map((n) => params[n]), diff --git a/pkgs/shelf_router/test/router_test.dart b/pkgs/shelf_router/test/router_test.dart index f664f563..def93336 100644 --- a/pkgs/shelf_router/test/router_test.dart +++ b/pkgs/shelf_router/test/router_test.dart @@ -202,4 +202,118 @@ void main() { final b2 = await Router.routeNotFound.readAsString(); expect(b2, b1); }); + + test('can mount dynamic routes', () async { + // routes for a specific [user]. The user value + // is extracted from the mount + Router createUsersRouter() { + var router = Router(); + + String getUser(Request r) => r.mountedParams['user']!; + + // Nested mount + // Routes for an [user] to [other]. This gets nested + // parameters from previous mounts + Router createUserToOtherRouter() { + var router = Router(); + + String getOtherUser(Request r) => r.mountedParams['other']!; + + router.get('/', (Request request, String action) { + return Response.ok( + '${getUser(request)} to ${getOtherUser(request)}: $action', + ); + }); + + return router; + } + + final userToOtherRouter = createUserToOtherRouter(); + router.mount( + '/to//', (Request r, String other) => userToOtherRouter(r)); + + router.get('/self', (Request request) { + return Response.ok("I'm ${getUser(request)}"); + }); + + router.get('/', (Request request) { + return Response.ok('${getUser(request)} root'); + }); + return router; + } + + var app = Router(); + app.get('/hello', (Request request) { + return Response.ok('hello-world'); + }); + + final usersRouter = createUsersRouter(); + app.mount('/users/', (Request r, String user) => usersRouter(r)); + + app.all('/<_|[^]*>', (Request request) { + return Response.ok('catch-all-handler'); + }); + + server.mount(app); + + expect(await get('/hello'), 'hello-world'); + expect(await get('/users/david/to/jake/salutes'), 'david to jake: salutes'); + expect(await get('/users/jennifer/to/mary/bye'), 'jennifer to mary: bye'); + expect(await get('/users/jennifer/self'), "I'm jennifer"); + expect(await get('/users/jake'), 'jake root'); + expect(await get('/users/david/no-route'), 'catch-all-handler'); + }); + + test('can mount dynamic routes with multiple parameters', () async { + var app = Router(); + + final mountedRouter = () { + var router = Router(); + + String getSecond(Request r) => r.mountedParams['second']!; + int getFourth(Request r) => int.parse(r.mountedParams['fourth']!); + + router.get( + '/', + (Request r) => Response.ok('${getSecond(r)} ${getFourth(r)}'), + ); + return router; + }(); + + app.mount( + r'/first//third//last', + (Request r, String second, String fourth) => mountedRouter(r), + ); + + server.mount(app); + + expect(await get('/first/hello/third/12/last'), 'hello 12'); + }); + + test('can mount dynamic routes with regexp', () async { + var app = Router(); + + final mountedRouter = () { + var router = Router(); + + int getBookId(Request r) => int.parse(r.mountedParams['bookId']!); + + router.get('/', (Request r) => Response.ok('book ${getBookId(r)}')); + return router; + }(); + + app.mount( + r'/before//after', + (Request r, String bookId) => mountedRouter(r), + ); + + app.all('/<_|[^]*>', (Request request) { + return Response.ok('catch-all-handler'); + }); + + server.mount(app); + + expect(await get('/before/123/after'), 'book 123'); + expect(await get('/before/abc/after'), 'catch-all-handler'); + }); }