Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support mounting dynamic routes. FIxes #250 #288

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
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');
});
}