Skip to content
139 changes: 124 additions & 15 deletions pkgs/shelf_router/lib/src/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user>', (Request r, String user) => usersRouter(r));
/// ```
///
/// If no parameters are captured this returns an empty map.
///
/// The returned map is unmodifiable.
Map<String, String> get mountedParams {
final p = context['shelf_router/mountedParams'];
if (p is Map<String, String>) {
return UnmodifiableMapView(p);
}
return _emptyParams;
}
}

/// Middleware to remove body from request.
Expand Down Expand Up @@ -113,6 +147,12 @@ class Router {
final List<RouterEntry> _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
Expand Down Expand Up @@ -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<path|[^]*>', (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<Response> _invokeMountedHandler(
Request request, Function handler, List<String> 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<String, String> 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/<path|[^]*>', (Request request) {
return handler(request.change(path: '$path/'));
});
// No parameters in the requested path
effectivePath = urlPath;
}
return effectivePath;
}

/// Route incoming requests to registered handlers.
Expand Down
26 changes: 24 additions & 2 deletions pkgs/shelf_router/lib/src/router_entry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -46,13 +54,14 @@ class RouterEntry {
List<String> 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);

Expand All @@ -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
Expand All @@ -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]),
Expand Down
114 changes: 114 additions & 0 deletions pkgs/shelf_router/test/router_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('/<action>', (Request request, String action) {
return Response.ok(
'${getUser(request)} to ${getOtherUser(request)}: $action',
);
});

return router;
}

final userToOtherRouter = createUserToOtherRouter();
router.mount(
'/to/<other>/', (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/<user>', (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/<second>/third/<fourth|\d+>/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/<bookId|\d+>/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');
});
}