Repository: VeryGoodOpenSource/dart_frog (dart)

Issue:
fix: middlewares wont work with dynamic routes
**Description**

Middlewares inside dynamic routes are not recognized.

**Steps To Reproduce**

1. Create a frog server with the following route structure
```
routes/
  - _middleware.dart
  - [id]/
    - index.dart
    - _middleware.dart
```
2. On the root middleware, put the following content:
```dart
import 'package:dart_frog/dart_frog.dart';

Handler middleware(Handler handler) {
  return handler
      .use(provider<String>((context) {
    return 'root middleware haha';
  }));
}
```
3. On the middleware under `/[id]`, put the following content:
```dart
import 'package:dart_frog/dart_frog.dart';

Handler middleware(Handler handler) {
  return handler
      .use(provider<String>((context) {
    return 'id middleware hihi';
  }));
}
```
4. On `/[id]/index.dart`  put the following content:
```dart
import 'package:dart_frog/dart_frog.dart';

Response onRequest(RequestContext context) {
  final thingy = context.read<String>();
  return Response(body: 'result: $thingy');
}

```
5. Start the server and go to `http://localhost:8080/someid`
6. See the result: `result: root middleware haha`


**Expected Behavior**

The handler at `/[id]/index.dart`  should; receive a context with information for the closest middleware, not the root one. 

**Additional Context**

Reproducible with the CLI version `0.0.2-dev.7`

Pull request:
<!--
  Thanks for contributing!

  Provide a description of your changes below and a general summary in the title

  Please look at the following checklist to ensure that your PR can be accepted quickly:
-->

## Status

**READY**

## Description

<!--- Describe your changes in detail -->
- fix: support mounting dynamic routes (closes #136)

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ] ✨ New feature (non-breaking change which adds functionality)
- [X] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore

Patch:
diff --git a/bricks/dart_frog_dev_server/__brick__/server.dart b/bricks/dart_frog_dev_server/__brick__/server.dart
index abadacba7..fa93a0c80 100644
--- a/bricks/dart_frog_dev_server/__brick__/server.dart
+++ b/bricks/dart_frog_dev_server/__brick__/server.dart
@@ -22,14 +22,14 @@ Future<HttpServer> createServer() {
 Handler buildRootHandler() {
   final pipeline = const Pipeline(){{#globalMiddleware}}.addMiddleware({{#snakeCase}}{{{name}}}{{/snakeCase}}.middleware){{/globalMiddleware}};
   final router = Router(){{#directories}}
-    ..mount('{{{route}}}', (r) => build{{#pascalCase}}{{{name}}}{{/pascalCase}}Handler()(r)){{/directories}};
+    ..mount('{{{route}}}', (ctx, {{#directory_params}}{{.}},{{/directory_params}}) => build{{#pascalCase}}{{{name}}}{{/pascalCase}}Handler({{#directory_params}}{{.}},{{/directory_params}})(ctx)){{/directories}};
   return pipeline.addHandler(router);
 }
 {{#directories}}
-Handler build{{#pascalCase}}{{{name}}}{{/pascalCase}}Handler() {
+Handler build{{#pascalCase}}{{{name}}}{{/pascalCase}}Handler({{#directory_params}}String {{.}},{{/directory_params}}) {
   {{#middleware}}final pipeline = const Pipeline(){{#middleware}}.addMiddleware({{#snakeCase}}{{{name}}}{{/snakeCase}}.middleware);{{/middleware}}{{/middleware}}{{^middleware}}const pipeline = Pipeline();{{/middleware}}
   final router = Router()
-    {{#files}}..all('{{{route}}}', {{#snakeCase}}{{{name}}}{{/snakeCase}}.onRequest){{/files}};
+    {{#files}}..all('{{{route}}}', (ctx,{{#file_params}}{{.}},{{/file_params}}) => {{#snakeCase}}{{{name}}}{{/snakeCase}}.onRequest(ctx, {{#directory_params}}{{.}},{{/directory_params}}{{#file_params}}{{.}},{{/file_params}})){{/files}};
   return pipeline.addHandler(router);
 }
 {{/directories}}
\ No newline at end of file
diff --git a/bricks/dart_frog_prod_server/__brick__/build/bin/server.dart b/bricks/dart_frog_prod_server/__brick__/build/bin/server.dart
index 3d1a0e54c..96835ae7b 100644
--- a/bricks/dart_frog_prod_server/__brick__/build/bin/server.dart
+++ b/bricks/dart_frog_prod_server/__brick__/build/bin/server.dart
@@ -22,14 +22,14 @@ Future<HttpServer> createServer() async {
 Handler buildRootHandler() {
   final pipeline = const Pipeline(){{#globalMiddleware}}.addMiddleware({{#snakeCase}}{{{name}}}{{/snakeCase}}.middleware){{/globalMiddleware}};
   final router = Router(){{#directories}}
-    ..mount('{{{route}}}', (r) => build{{#pascalCase}}{{{name}}}{{/pascalCase}}Handler()(r)){{/directories}};
+    ..mount('{{{route}}}', (ctx, {{#directory_params}}{{.}},{{/directory_params}}) => build{{#pascalCase}}{{{name}}}{{/pascalCase}}Handler({{#directory_params}}{{.}},{{/directory_params}})(ctx)){{/directories}};
   return pipeline.addHandler(router);
 }
 {{#directories}}
-Handler build{{#pascalCase}}{{{name}}}{{/pascalCase}}Handler() {
+Handler build{{#pascalCase}}{{{name}}}{{/pascalCase}}Handler({{#directory_params}}String {{.}},{{/directory_params}}) {
   final pipeline = const Pipeline(){{#middleware}}.addMiddleware({{#snakeCase}}{{{name}}}{{/snakeCase}}.middleware){{/middleware}};
   final router = Router()
-    {{#files}}..all('{{{route}}}', {{#snakeCase}}{{{name}}}{{/snakeCase}}.onRequest){{/files}};
+    {{#files}}..all('{{{route}}}', (ctx,{{#file_params}}{{.}},{{/file_params}}) => {{#snakeCase}}{{{name}}}{{/snakeCase}}.onRequest(ctx, {{#directory_params}}{{.}},{{/directory_params}}{{#file_params}}{{.}},{{/file_params}})){{/files}};
   return pipeline.addHandler(router);
 }
 {{/directories}}
\ No newline at end of file
diff --git a/packages/dart_frog/lib/src/_internal.dart b/packages/dart_frog/lib/src/_internal.dart
index 48978e27b..3e70aac3c 100644
--- a/packages/dart_frog/lib/src/_internal.dart
+++ b/packages/dart_frog/lib/src/_internal.dart
@@ -1,3 +1,4 @@
+import 'dart:collection';
 import 'dart:convert';
 import 'dart:io';
 
diff --git a/packages/dart_frog/lib/src/router.dart b/packages/dart_frog/lib/src/router.dart
index e2dc85a83..563771c26 100644
--- a/packages/dart_frog/lib/src/router.dart
+++ b/packages/dart_frog/lib/src/router.dart
@@ -39,6 +39,13 @@ class Router {
   Router({Handler notFoundHandler = _defaultNotFound})
       : _notFoundHandler = notFoundHandler;
 
+  /// Name of the parameter used for matching
+  /// the rest of the path in a mounted route.
+  ///
+  /// Two underscore prefix to avoid conflicts
+  /// with user-defined path parameters.
+  static const _kMountedPathParamRest = '__path';
+
   final List<RouterEntry> _routes = [];
   final Handler _notFoundHandler;
 
@@ -65,37 +72,83 @@ class Router {
 
   /// Handle all request to [route] using [handler].
   void all(String route, Function handler) {
-    _routes.add(RouterEntry('ALL', route, handler));
+    _all(route, handler, mounted: false);
+  }
+
+  void _all(String route, Function handler, {required bool mounted}) {
+    _routes.add(RouterEntry('ALL', route, handler, mounted: mounted));
   }
 
   /// 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');
     }
 
-    // The first slash is always in request.handlerPath
-    final path = prefix.substring(1);
     if (prefix.endsWith('/')) {
-      all('$prefix<path|[^]*>', (RequestContext context) {
-        return handler(
-          RequestContext._(context.request._request.change(path: path)),
-        );
-      });
+      _all(
+        '$prefix<$_kMountedPathParamRest|[^]*>',
+        (RequestContext context, List<String> params) {
+          return _invokeMountedHandler(
+            context,
+            handler,
+            // Remove path param from extracted route params
+            [...params]..removeLast(),
+          );
+        },
+        mounted: true,
+      );
+    } else {
+      _all(
+        prefix,
+        (RequestContext context, List<String> params) {
+          return _invokeMountedHandler(context, handler, params);
+        },
+        mounted: true,
+      );
+      _all(
+        '$prefix/<$_kMountedPathParamRest|[^]*>',
+        (RequestContext context, List<String> params) {
+          return _invokeMountedHandler(
+            context,
+            handler,
+            // Remove path param from extracted route params
+            [...params]..removeLast(),
+          );
+        },
+        mounted: true,
+      );
+    }
+  }
+
+  Future<Response> _invokeMountedHandler(
+    RequestContext context,
+    Function handler,
+    List<String> pathParams,
+  ) async {
+    final request = context.request;
+    final params = request._request.params;
+    final pathParamSegment = params[_kMountedPathParamRest];
+    final urlPath = request.url.path;
+    late final String effectivePath;
+    if (pathParamSegment != null && pathParamSegment.isNotEmpty) {
+      /// If we encounter the `_kMountedPathParamRest` parameter we remove it
+      /// from the request path that shelf will handle.
+      effectivePath = urlPath.substring(
+        0,
+        urlPath.length - pathParamSegment.length,
+      );
     } else {
-      all(prefix, (RequestContext context) {
-        return handler(
-          RequestContext._(context.request._request.change(path: path)),
-        );
-      });
-      all('$prefix/<path|[^]*>', (RequestContext context) {
-        return handler(
-          RequestContext._(context.request._request.change(path: '$path/')),
-        );
-      });
+      effectivePath = urlPath;
     }
+    final modifiedRequestContext = RequestContext._(
+      request._request.change(path: effectivePath),
+    );
+
+    return await Function.apply(handler, [
+      modifiedRequestContext,
+      ...pathParams.map((param) => params[param]),
+    ]) as Response;
   }
 
   /// Route incoming requests to registered handlers.
@@ -196,6 +249,7 @@ class RouterEntry {
     String route,
     Function handler, {
     Middleware? middleware,
+    bool mounted = false,
   }) {
     middleware = middleware ?? ((Handler fn) => fn);
 
@@ -233,6 +287,7 @@ class RouterEntry {
       middleware,
       routePattern,
       params,
+      mounted,
     );
   }
 
@@ -243,6 +298,7 @@ class RouterEntry {
     this._middleware,
     this._routePattern,
     this._params,
+    this._mounted,
   );
 
   /// Pattern for parsing the route pattern
@@ -253,14 +309,19 @@ class RouterEntry {
   final Function _handler;
   final Middleware _middleware;
 
+  /// Indicates this entry is used as a mounting point.
+  final bool _mounted;
+
   /// Expression that the request path must match.
   ///
   /// This also captures any parameters in the route pattern.
   final RegExp _routePattern;
 
-  /// Names for the parameters in the route pattern.
   final List<String> _params;
 
+  /// Names for the parameters in the route pattern.
+  List<String> get params => _params.toList();
+
   /// Returns a map from parameter name to value, if the path matches the
   /// route pattern. Otherwise returns null.
   Map<String, String>? match(String path) {
@@ -287,6 +348,13 @@ class RouterEntry {
     final _context = RequestContext._(request);
 
     return await _middleware((request) async {
+      if (_mounted) {
+        // if this route is mounted, we include
+        // the route entry params so that the mount can extract the parameters/
+        // ignore: avoid_dynamic_calls
+        return await _handler(_context, this.params) as Response;
+      }
+
       if (_handler is Handler || _params.isEmpty) {
         // ignore: avoid_dynamic_calls
         return await _handler(_context) as Response;
@@ -300,3 +368,21 @@ class RouterEntry {
     })(_context);
   }
 }
+
+final _emptyParams = UnmodifiableMapView(<String, String>{});
+
+/// Extension on [shelf.Request] which provides access to
+/// URL parameters captured by the [Router].
+extension RouterParams on shelf.Request {
+  /// Get URL parameters captured by the [Router].
+  /// 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;
+  }
+}
diff --git a/packages/dart_frog_gen/lib/src/build_route_configuration.dart b/packages/dart_frog_gen/lib/src/build_route_configuration.dart
index 1c07d08c6..84b882a8c 100644
--- a/packages/dart_frog_gen/lib/src/build_route_configuration.dart
+++ b/packages/dart_frog_gen/lib/src/build_route_configuration.dart
@@ -88,20 +88,12 @@ List<RouteDirectory> _getRouteDirectories({
     }
   }
 
-  final files = [
-    ..._getRouteFiles(
-      directory: directory,
-      routesDirectory: routesDirectory,
-      onRoute: onRoute,
-      onRogueRoute: onRogueRoute,
-    ),
-    ..._getRouteFilesForDynamicDirectories(
-      directory: directory,
-      routesDirectory: routesDirectory,
-      onRoute: onRoute,
-      onRogueRoute: onRogueRoute,
-    ),
-  ];
+  final files = _getRouteFiles(
+    directory: directory,
+    routesDirectory: routesDirectory,
+    onRoute: onRoute,
+    onRogueRoute: onRogueRoute,
+  );
 
   final baseRoute = directoryPath.toRoute();
   for (final file in files) {
@@ -119,61 +111,26 @@ List<RouteDirectory> _getRouteDirectories({
       route: baseRoute,
       middleware: middleware,
       files: files,
+      params: directoryPath.toParams(),
     ),
   );
 
   entities.whereType<Directory>().forEach((directory) {
-    if (!directory.isDynamicRoute) {
-      directories.addAll(
-        _getRouteDirectories(
-          directory: directory,
-          routesDirectory: routesDirectory,
-          onRoute: onRoute,
-          onMiddleware: onMiddleware,
-          onEndpoint: onEndpoint,
-          onRogueRoute: onRogueRoute,
-        ),
-      );
-    }
+    directories.addAll(
+      _getRouteDirectories(
+        directory: directory,
+        routesDirectory: routesDirectory,
+        onRoute: onRoute,
+        onMiddleware: onMiddleware,
+        onEndpoint: onEndpoint,
+        onRogueRoute: onRogueRoute,
+      ),
+    );
   });
 
   return directories;
 }
 
-List<RouteFile> _getRouteFilesForDynamicDirectories({
-  required Directory directory,
-  required Directory routesDirectory,
-  required void Function(RouteFile route) onRoute,
-  required void Function(RouteFile route) onRogueRoute,
-  String prefix = '',
-}) {
-  final files = <RouteFile>[];
-  directory
-      .listSync()
-      .sorted()
-      .whereType<Directory>()
-      .where((d) => prefix.isNotEmpty || d.isDynamicRoute)
-      .forEach((dynamicDirectory) {
-    final newPrefix = '$prefix/${path.basename(dynamicDirectory.path)}';
-    final subset = _getRouteFiles(
-      directory: dynamicDirectory,
-      routesDirectory: routesDirectory,
-      onRoute: onRoute,
-      onRogueRoute: onRogueRoute,
-      prefix: newPrefix,
-    );
-    final dynamicSubset = _getRouteFilesForDynamicDirectories(
-      directory: dynamicDirectory,
-      routesDirectory: routesDirectory,
-      onRoute: onRoute,
-      onRogueRoute: onRogueRoute,
-      prefix: newPrefix,
-    );
-    files.addAll([...subset, ...dynamicSubset]);
-  });
-  return files;
-}
-
 List<RouteFile> _getRouteFiles({
   required Directory directory,
   required Directory routesDirectory,
@@ -207,12 +164,7 @@ List<RouteFile> _getRouteFiles({
       var fileRoute = fileRoutePath.isEmpty ? '/' : fileRoutePath;
       fileRoute = prefix + fileRoute;
 
-      if (!fileRoute.startsWith('/')) {
-        fileRoute = '/$fileRoute';
-      }
-      if (fileRoute != '/' && fileRoute.endsWith('/')) {
-        fileRoute = fileRoute.substring(0, fileRoute.length - 1);
-      }
+      if (!fileRoute.startsWith('/')) fileRoute = '/$fileRoute';
 
       return fileRoute;
     }
@@ -223,6 +175,7 @@ List<RouteFile> _getRouteFiles({
       name: filePath.toAlias(),
       path: relativeFilePath.replaceAll(r'\', '/'),
       route: fileRoute.toRoute(),
+      params: fileRoute.toParams(),
     );
     onRoute(route);
     files.add(route);
@@ -253,6 +206,12 @@ extension on String {
   String toRoute() {
     return replaceAll('[', '<').replaceAll(']', '>').replaceAll(r'\', '/');
   }
+
+  List<String> toParams() {
+    final regexp = RegExp(r'\[(.*?)\]');
+    final matches = regexp.allMatches(this);
+    return matches.map((m) => m[0]!.replaceAll(RegExp(r'\[|\]'), '')).toList();
+  }
 }
 
 extension on List<FileSystemEntity> {
@@ -261,12 +220,6 @@ extension on List<FileSystemEntity> {
   }
 }
 
-extension on Directory {
-  bool get isDynamicRoute {
-    return RegExp(r'\[(.*)\]').hasMatch(path.basename(this.path));
-  }
-}
-
 extension on FileSystemEntity {
   bool get isRoute {
     return this is File &&
@@ -354,6 +307,7 @@ class RouteDirectory {
     required this.route,
     required this.middleware,
     required this.files,
+    required this.params,
   });
 
   /// The alias for the current directory.
@@ -362,6 +316,9 @@ class RouteDirectory {
   /// The route which will be used to mount routers.
   final String route;
 
+  /// The dynamic route params associated with the directory.
+  final List<String> params;
+
   /// Optional middleware for the provided router.
   final MiddlewareFile? middleware;
 
@@ -374,12 +331,14 @@ class RouteDirectory {
     String? route,
     MiddlewareFile? middleware,
     List<RouteFile>? files,
+    List<String>? params,
   }) {
     return RouteDirectory(
       name: name ?? this.name,
       route: route ?? this.route,
       middleware: middleware ?? this.middleware,
       files: files ?? this.files,
+      params: params ?? this.params,
     );
   }
 
@@ -390,6 +349,7 @@ class RouteDirectory {
       'route': route,
       'middleware': middleware?.toJson() ?? false,
       'files': files.map((f) => f.toJson()).toList(),
+      'directory_params': params,
     };
   }
 }
@@ -403,6 +363,7 @@ class RouteFile {
     required this.name,
     required this.path,
     required this.route,
+    required this.params,
   });
 
   /// The alias for the current file.
@@ -414,12 +375,21 @@ class RouteFile {
   /// The route used by router instances.
   final String route;
 
+  /// The dynamic route params associated with the file.
+  final List<String> params;
+
   /// Create a copy of the current instance and override zero or more values.
-  RouteFile copyWith({String? name, String? path, String? route}) {
+  RouteFile copyWith({
+    String? name,
+    String? path,
+    String? route,
+    List<String>? params,
+  }) {
     return RouteFile(
       name: name ?? this.name,
       path: path ?? this.path,
       route: route ?? this.route,
+      params: params ?? this.params,
     );
   }
 
@@ -429,6 +399,7 @@ class RouteFile {
       'name': name,
       'path': path,
       'route': route,
+      'file_params': params,
     };
   }
 }


Test patch:
diff --git a/packages/dart_frog/test/src/router_test.dart b/packages/dart_frog/test/src/router_test.dart
index cfeb1e5cc..37e3198ce 100644
--- a/packages/dart_frog/test/src/router_test.dart
+++ b/packages/dart_frog/test/src/router_test.dart
@@ -27,6 +27,8 @@ import 'package:test/test.dart';
 
 class _MockRequestContext extends Mock implements RequestContext {}
 
+class _MockShelfRequest extends Mock implements shelf.Request {}
+
 void main() {
   // Create a server that listens on localhost for testing
   late io.IOServer server;
@@ -441,6 +443,185 @@ void main() {
     expect(response.body(), completion(equals('Route not found')));
   });
 
+  test('can mount route without params', () async {
+    final context = _MockRequestContext();
+    final app = Router()..mount('/', (RequestContext context) => Response());
+
+    server.mount((request) async {
+      when(() => context.request).thenReturn(
+        Request(request.method, request.requestedUri),
+      );
+      final response = await app.call(context);
+      final body = await response.body();
+      return shelf.Response(response.statusCode, body: body);
+    });
+
+    final response = await http.get(Uri.parse('${server.url}/'));
+    expect(response.body, isEmpty);
+    expect(response.statusCode, equals(HttpStatus.ok));
+  });
+
+  test('can mount dynamic route', () async {
+    final context = _MockRequestContext();
+    final app = Router()
+      ..mount('/api/v1/posts/<id>', (RequestContext context, String id) {
+        return Response(body: '/api/v1/posts/$id');
+      });
+    server.mount((request) async {
+      when(() => context.request).thenReturn(
+        Request(request.method, request.requestedUri),
+      );
+      final response = await app.call(context);
+      final body = await response.body();
+      return shelf.Response(response.statusCode, body: body);
+    });
+
+    final response = await http.get(Uri.parse('${server.url}/api/v1/posts/42'));
+    expect(response.body, equals('/api/v1/posts/42'));
+  });
+
+  test('can mount dynamic routes', () async {
+    final context = _MockRequestContext();
+
+    // Routes for <user> to <other>.
+    // This gets nested parameters from previous mounts.
+    Handler createUserToOtherHandler(String user, String other) {
+      final router = Router()
+        ..get('/<action>', (RequestContext context, String action) {
+          return Response(body: '$user to $other: $action');
+        });
+
+      return router;
+    }
+
+    // Routes for a specific <user>.
+    // The user value is extracted from the mount.
+    Handler createUserHandler(String user) {
+      final router = Router()
+        ..mount('/to/<other>/', (RequestContext context, String other) {
+          final handler = createUserToOtherHandler(user, other);
+          return handler(context);
+        })
+        ..get('/self', (RequestContext context) {
+          return Response(body: "I'm $user");
+        })
+        ..get('/', (RequestContext context) {
+          return Response(body: '$user root');
+        });
+      return router;
+    }
+
+    final app = Router()
+      ..get('/hello', (RequestContext context) {
+        return Response(body: 'hello-world');
+      })
+      ..mount('/users/<user>', (RequestContext context, String user) {
+        final handler = createUserHandler(user);
+        return handler(context);
+      })
+      ..all('/<_|[^]*>', (RequestContext context) {
+        return Response(body: 'catch-all-handler');
+      });
+
+    server.mount((request) async {
+      when(() => context.request).thenReturn(
+        Request(request.method, request.requestedUri),
+      );
+      final response = await app.call(context);
+      final body = await response.body();
+      return shelf.Response(response.statusCode, body: body);
+    });
+
+    final helloResponse = await http.get(Uri.parse('${server.url}/hello'));
+    expect(helloResponse.body, equals('hello-world'));
+
+    final greetingResponse = await http.get(
+      Uri.parse('${server.url}/users/dartfrog/to/dash/hi'),
+    );
+    expect(greetingResponse.body, equals('dartfrog to dash: hi'));
+
+    final farewellResponse = await http.get(
+      Uri.parse('${server.url}/users/dash/to/dartfrog/bye'),
+    );
+    expect(farewellResponse.body, equals('dash to dartfrog: bye'));
+
+    final introResponse = await http.get(
+      Uri.parse('${server.url}/users/dash/self'),
+    );
+    expect(introResponse.body, equals("I'm dash"));
+
+    final rootResponse = await http.get(
+      Uri.parse('${server.url}/users/dartfrog'),
+    );
+    expect(rootResponse.body, equals('dartfrog root'));
+
+    final catchAllResponse = await http.get(
+      Uri.parse('${server.url}/users/dartfrog/no-route'),
+    );
+    expect(catchAllResponse.body, equals('catch-all-handler'));
+  });
+
+  test('can mount dynamic routes with multiple parameters', () async {
+    final context = _MockRequestContext();
+    final app = Router()
+      ..mount(r'/first/<second>/third/<fourth|\d+>/last', (
+        RequestContext context,
+        String second,
+        String fourthNum,
+      ) {
+        final router = Router()
+          ..get('/', (r) => Response(body: '$second ${int.parse(fourthNum)}'));
+        return router(context);
+      });
+
+    server.mount((request) async {
+      when(() => context.request).thenReturn(
+        Request(request.method, request.requestedUri),
+      );
+      final response = await app.call(context);
+      final body = await response.body();
+      return shelf.Response(response.statusCode, body: body);
+    });
+
+    final response = await http.get(
+      Uri.parse('${server.url}/first/hello/third/42/last'),
+    );
+    expect(response.body, equals('hello 42'));
+  });
+
+  test('can mount dynamic routes with regexp', () async {
+    final context = _MockRequestContext();
+    final app = Router()
+      ..mount(r'/before/<bookId|\d+>/after',
+          (RequestContext context, String bookId) {
+        final router = Router()
+          ..get('/', (r) => Response(body: 'book ${int.parse(bookId)}'));
+        return router(context);
+      })
+      ..all('/<_|[^]*>', (RequestContext context) {
+        return Response(body: 'catch-all-handler');
+      });
+
+    server.mount((request) async {
+      when(() => context.request).thenReturn(
+        Request(request.method, request.requestedUri),
+      );
+      final response = await app.call(context);
+      final body = await response.body();
+      return shelf.Response(response.statusCode, body: body);
+    });
+
+    final bookResponse = await http.get(
+      Uri.parse('${server.url}/before/42/after'),
+    );
+    expect(bookResponse.body, equals('book 42'));
+
+    final catchAllResponse = await http.get(
+      Uri.parse('${server.url}/before/abc/after'),
+    );
+    expect(catchAllResponse.body, equals('catch-all-handler'));
+  });
+
   group('RouterEntry', () {
     void testPattern(
       String pattern, {
@@ -464,13 +645,8 @@ void main() {
 
     testPattern(
       '/hello',
-      match: {
-        '/hello': {},
-      },
-      notMatch: [
-        '/not-hello',
-        '/',
-      ],
+      match: {'/hello': {}},
+      notMatch: ['/not-hello', '/'],
     );
 
     testPattern(
@@ -507,4 +683,21 @@ void main() {
       );
     });
   });
+
+  group('RouterParams', () {
+    test('returns empty params when none are found', () async {
+      final request = _MockShelfRequest();
+      when(() => request.context).thenReturn({});
+
+      expect(request.params, isEmpty);
+    });
+
+    test('returns params when they are found', () async {
+      const params = {'foo': 'bar'};
+      final request = _MockShelfRequest();
+      when(() => request.context).thenReturn({'shelf_router/params': params});
+
+      expect(request.params, equals(params));
+    });
+  });
 }
diff --git a/packages/dart_frog_gen/test/src/build_route_configuration_test.dart b/packages/dart_frog_gen/test/src/build_route_configuration_test.dart
index 01badc277..ed4fb823d 100644
--- a/packages/dart_frog_gen/test/src/build_route_configuration_test.dart
+++ b/packages/dart_frog_gen/test/src/build_route_configuration_test.dart
@@ -106,8 +106,14 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'}
-          ]
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            }
+          ],
+          'directory_params': <String>[],
         }
       ];
       final directory = Directory(
@@ -142,9 +148,20 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'},
-            {'name': 'hello', 'path': '../routes/hello.dart', 'route': '/hello'}
-          ]
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            },
+            {
+              'name': 'hello',
+              'path': '../routes/hello.dart',
+              'route': '/hello',
+              'file_params': <String>[],
+            }
+          ],
+          'directory_params': <String>[],
         }
       ];
       final directory = Directory(
@@ -192,8 +209,14 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'}
-          ]
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            }
+          ],
+          'directory_params': <String>[],
         },
         {
           'name': '_echo',
@@ -203,9 +226,11 @@ void main() {
             {
               'name': 'echo_message',
               'path': '../routes/echo/message.dart',
-              'route': '/message'
+              'route': '/message',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>[],
         }
       ];
       final directory = Directory(
@@ -245,12 +270,19 @@ void main() {
 
     test('includes nested directories', () {
       const expected = [
-        {'name': '_', 'route': '/', 'middleware': false, 'files': <dynamic>[]},
+        {
+          'name': '_',
+          'route': '/',
+          'middleware': false,
+          'files': <dynamic>[],
+          'directory_params': <String>[],
+        },
         {
           'name': '_echo',
           'route': '/echo',
           'middleware': false,
-          'files': <dynamic>[]
+          'files': <dynamic>[],
+          'directory_params': <String>[],
         },
         {
           'name': '_echo_message',
@@ -260,9 +292,11 @@ void main() {
             {
               'name': 'echo_message_index',
               'path': '../routes/echo/message/index.dart',
-              'route': '/'
+              'route': '/',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>[],
         }
       ];
       final directory = Directory(
@@ -306,8 +340,14 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'}
-          ]
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            }
+          ],
+          'directory_params': <String>[],
         },
         {
           'name': '_echo',
@@ -317,9 +357,11 @@ void main() {
             {
               'name': r'echo_$message',
               'path': '../routes/echo/[message].dart',
-              'route': '/<message>'
+              'route': '/<message>',
+              'file_params': <String>['message'],
             }
-          ]
+          ],
+          'directory_params': <String>[],
         }
       ];
       final directory = Directory(
@@ -364,18 +406,45 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'},
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            },
+          ],
+          'directory_params': <String>[],
+        },
+        {
+          'name': r'_$user',
+          'route': '/<user>',
+          'middleware': false,
+          'files': [
             {
               'name': r'$user_$name',
               'path': '../routes/[user]/[name].dart',
-              'route': '/<user>/<name>'
-            },
+              'route': '/<name>',
+              'file_params': ['name'],
+            }
+          ],
+          'directory_params': <String>['user'],
+        },
+        {
+          'name': r'_$user_$id',
+          'route': '/<user>/<id>',
+          'middleware': {
+            'name': r'$user_$id__middleware',
+            'path': '../routes/[user]/[id]/_middleware.dart'
+          },
+          'files': [
             {
               'name': r'$user_$id_index',
               'path': '../routes/[user]/[id]/index.dart',
-              'route': '/<user>/<id>'
+              'route': '/',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>['user', 'id'],
         }
       ];
       final directory = Directory(
@@ -395,6 +464,7 @@ void main() {
       final idDirectory = Directory(path.join(userDirectory.path, '[id]'))
         ..createSync();
       File(path.join(idDirectory.path, 'index.dart')).createSync();
+      File(path.join(idDirectory.path, '_middleware.dart')).createSync();
       final configuration = buildRouteConfiguration(directory);
       expect(
         configuration.directories.map((d) => d.toJson()).toList(),
@@ -435,13 +505,35 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'},
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            },
+          ],
+          'directory_params': <String>[],
+        },
+        {
+          'name': r'_$id',
+          'route': '/<id>',
+          'middleware': false,
+          'files': <Map<String, dynamic>>[],
+          'directory_params': <String>['id'],
+        },
+        {
+          'name': r'_$id_api',
+          'route': '/<id>/api',
+          'middleware': false,
+          'files': [
             {
               'name': r'$id_api_index',
               'path': '../routes/[id]/api/index.dart',
-              'route': '/<id>/api'
+              'route': '/',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>['id'],
         }
       ];
       final directory = Directory(
@@ -493,13 +585,35 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'},
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            },
+          ],
+          'directory_params': <String>[],
+        },
+        {
+          'name': r'_$id',
+          'route': '/<id>',
+          'middleware': false,
+          'files': <Map<String, dynamic>>[],
+          'directory_params': <String>['id'],
+        },
+        {
+          'name': r'_$id_api',
+          'route': '/<id>/api',
+          'middleware': false,
+          'files': [
             {
               'name': r'$id_api_test',
               'path': '../routes/[id]/api/test.dart',
-              'route': '/<id>/api/test'
+              'route': '/test',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>['id'],
         }
       ];
       final directory = Directory(
@@ -551,13 +665,42 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'},
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            },
+          ],
+          'directory_params': <String>[],
+        },
+        {
+          'name': r'_$id',
+          'route': '/<id>',
+          'middleware': false,
+          'files': <Map<String, dynamic>>[],
+          'directory_params': <String>['id'],
+        },
+        {
+          'name': r'_$id_api',
+          'route': '/<id>/api',
+          'middleware': false,
+          'files': <Map<String, dynamic>>[],
+          'directory_params': <String>['id'],
+        },
+        {
+          'name': r'_$id_api_$name',
+          'route': '/<id>/api/<name>',
+          'middleware': false,
+          'files': [
             {
               'name': r'$id_api_$name_index',
               'path': '../routes/[id]/api/[name]/index.dart',
-              'route': '/<id>/api/<name>'
+              'route': '/',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>['id', 'name'],
         }
       ];
       final directory = Directory(
@@ -611,13 +754,42 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'index', 'path': '../routes/index.dart', 'route': '/'},
+            {
+              'name': 'index',
+              'path': '../routes/index.dart',
+              'route': '/',
+              'file_params': <String>[],
+            },
+          ],
+          'directory_params': <String>[],
+        },
+        {
+          'name': r'_$id',
+          'route': '/<id>',
+          'middleware': false,
+          'files': <Map<String, dynamic>>[],
+          'directory_params': <String>['id'],
+        },
+        {
+          'name': r'_$id_api',
+          'route': '/<id>/api',
+          'middleware': false,
+          'files': <Map<String, dynamic>>[],
+          'directory_params': <String>['id'],
+        },
+        {
+          'name': r'_$id_api_$name',
+          'route': '/<id>/api/<name>',
+          'middleware': false,
+          'files': [
             {
               'name': r'$id_api_$name_test',
               'path': '../routes/[id]/api/[name]/test.dart',
-              'route': '/<id>/api/<name>/test'
+              'route': '/test',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>['id', 'name'],
         }
       ];
       final directory = Directory(
@@ -666,7 +838,13 @@ void main() {
 
     test('supports /api/api.dart', () {
       const expected = [
-        {'name': '_', 'route': '/', 'middleware': false, 'files': <dynamic>[]},
+        {
+          'name': '_',
+          'route': '/',
+          'middleware': false,
+          'files': <dynamic>[],
+          'directory_params': <String>[],
+        },
         {
           'name': '_api',
           'route': '/api',
@@ -675,9 +853,11 @@ void main() {
             {
               'name': 'api_api',
               'path': '../routes/api/api.dart',
-              'route': '/api'
+              'route': '/api',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>[],
         }
       ];
       final directory = Directory(
@@ -719,21 +899,34 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'api', 'path': '../routes/api.dart', 'route': '/api'}
-          ]
+            {
+              'name': 'api',
+              'path': '../routes/api.dart',
+              'route': '/api',
+              'file_params': <String>[],
+            }
+          ],
+          'directory_params': <String>[],
         },
         {
           'name': '_api',
           'route': '/api',
           'middleware': false,
           'files': [
-            {'name': 'api_v1', 'path': '../routes/api/v1.dart', 'route': '/v1'},
+            {
+              'name': 'api_v1',
+              'path': '../routes/api/v1.dart',
+              'route': '/v1',
+              'file_params': <String>[],
+            },
             {
               'name': r'api_$id',
               'path': '../routes/api/[id].dart',
-              'route': '/<id>'
+              'route': '/<id>',
+              'file_params': <String>['id'],
             }
-          ]
+          ],
+          'directory_params': <String>[],
         },
         {
           'name': '_api_v1',
@@ -743,9 +936,11 @@ void main() {
             {
               'name': 'api_v1_hello',
               'path': '../routes/api/v1/hello.dart',
-              'route': '/hello'
+              'route': '/hello',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>[],
         }
       ];
       final directory = Directory(
@@ -828,8 +1023,14 @@ void main() {
           'route': '/',
           'middleware': false,
           'files': [
-            {'name': 'api', 'path': '../routes/api.dart', 'route': '/api'}
-          ]
+            {
+              'name': 'api',
+              'path': '../routes/api.dart',
+              'route': '/api',
+              'file_params': <String>[],
+            }
+          ],
+          'directory_params': <String>[],
         },
         {
           'name': '_api',
@@ -839,9 +1040,11 @@ void main() {
             {
               'name': 'api_index',
               'path': '../routes/api/index.dart',
-              'route': '/'
+              'route': '/',
+              'file_params': <String>[],
             }
-          ]
+          ],
+          'directory_params': <String>[],
         }
       ];
       final directory = Directory(
diff --git a/packages/dart_frog_gen/test/src/route_directory_test.dart b/packages/dart_frog_gen/test/src/route_directory_test.dart
index c28f72095..2b939891f 100644
--- a/packages/dart_frog_gen/test/src/route_directory_test.dart
+++ b/packages/dart_frog_gen/test/src/route_directory_test.dart
@@ -11,12 +11,14 @@ void main() {
         route: '/',
         middleware: null,
         files: [],
+        params: [],
       );
       final copy = original.copyWith();
       expect(copy.name, equals(original.name));
       expect(copy.route, equals(original.route));
       expect(copy.middleware, original.middleware);
       expect(copy.files, original.files);
+      expect(copy.params, original.params);
     });
 
     test('copyWith creates a copy (updates)', () {
@@ -25,39 +27,52 @@ void main() {
         route: '/',
         middleware: null,
         files: [],
+        params: [],
       );
       final copy = original.copyWith(
         name: 'copy',
         route: '/copy',
         middleware: MiddlewareFile(name: '/', path: '/'),
         files: [],
+        params: ['copy'],
       );
       expect(copy.name, equals('copy'));
       expect(copy.route, equals('/copy'));
       expect(copy.middleware, isNotNull);
       expect(copy.files, isEmpty);
+      expect(copy.params, equals(['copy']));
     });
 
     test('toJson returns correct map', () {
       final routeDirectory = RouteDirectory(
         name: 'index',
-        route: '/',
+        route: '/api/v1/users/<id>',
         middleware: null,
-        files: [RouteFile(name: 'name', path: '/path', route: '/route')],
+        files: [
+          RouteFile(
+            name: 'name',
+            path: '/path',
+            route: '/route/<name>',
+            params: ['name'],
+          )
+        ],
+        params: ['id'],
       );
       expect(
         routeDirectory.toJson(),
         equals(<String, dynamic>{
           'name': 'index',
-          'route': '/',
+          'route': '/api/v1/users/<id>',
           'middleware': false,
           'files': [
             <String, dynamic>{
               'name': 'name',
               'path': '/path',
-              'route': '/route'
+              'route': '/route/<name>',
+              'file_params': ['name'],
             }
-          ]
+          ],
+          'directory_params': <String>['id'],
         }),
       );
     });
diff --git a/packages/dart_frog_gen/test/src/route_file_test.dart b/packages/dart_frog_gen/test/src/route_file_test.dart
index 1caba56ae..10bff8241 100644
--- a/packages/dart_frog_gen/test/src/route_file_test.dart
+++ b/packages/dart_frog_gen/test/src/route_file_test.dart
@@ -6,31 +6,54 @@ import 'package:test/test.dart';
 void main() {
   group('RouteFile', () {
     test('copyWith creates a copy (no updates)', () {
-      final original = RouteFile(name: 'index', route: '/', path: '/path');
+      final original = RouteFile(
+        name: 'index',
+        route: '/',
+        path: '/path',
+        params: <String>[],
+      );
       final copy = original.copyWith();
       expect(copy.name, equals(original.name));
       expect(copy.route, equals(original.route));
       expect(copy.path, equals(original.path));
+      expect(copy.params, equals(original.params));
     });
 
     test('copyWith creates a copy (updates)', () {
-      final original = RouteFile(name: 'index', route: '/', path: '/path');
+      final original = RouteFile(
+        name: 'index',
+        route: '/',
+        path: '/path',
+        params: <String>[],
+      );
       final copy = original.copyWith(
         name: 'copy',
         route: '/copy',
         path: '/copy',
+        params: ['copy'],
       );
       expect(copy.name, equals('copy'));
       expect(copy.route, equals('/copy'));
       expect(copy.path, equals('/copy'));
+      expect(copy.params, equals(['copy']));
     });
 
     test('toJson returns correct map', () {
-      final routeFile = RouteFile(name: 'index', route: '/', path: '/path');
+      final routeFile = RouteFile(
+        name: 'index',
+        route: '/<id>',
+        path: '/path',
+        params: <String>['id'],
+      );
       expect(
         routeFile.toJson(),
         equals(
-          <String, dynamic>{'name': 'index', 'path': '/path', 'route': '/'},
+          <String, dynamic>{
+            'name': 'index',
+            'path': '/path',
+            'route': '/<id>',
+            'file_params': ['id']
+          },
         ),
       );
     });