From a9e4f6d4dc6d372033fa2229cf2c2ebccb41111e Mon Sep 17 00:00:00 2001 From: thosakwe Date: Wed, 19 Oct 2016 18:04:06 -0400 Subject: [PATCH] Still need more browser tests, add server tests, fix kinks --- README.md | 2 +- lib/angel_route.dart | 1 + lib/browser.dart | 10 ++- lib/src/route.dart | 107 +++++++++++++++++++++++++++------ lib/src/router.dart | 75 +++++++++++++++++++++-- lib/src/routing_exception.dart | 5 ++ pubspec.yaml | 2 +- test/route/with_params.dart | 51 ++++++++-------- test/router/all_tests.dart | 15 +++-- uri.dart | 5 -- web/shared/basic.dart | 1 + 11 files changed, 208 insertions(+), 66 deletions(-) delete mode 100644 uri.dart diff --git a/README.md b/README.md index 95659a0f..95329b93 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ main() { return someQuery(id).reviews.firstWhere( (r) => r.id == reviewId); }); - }, before: [put, middleware, here]); + }, middleware: [put, middleware, here]); } ``` diff --git a/lib/angel_route.dart b/lib/angel_route.dart index 8dadd5b7..228185e3 100644 --- a/lib/angel_route.dart +++ b/lib/angel_route.dart @@ -1,3 +1,4 @@ +/// A powerful, isomorphic routing library for Dart. library angel_route; export 'src/route.dart'; diff --git a/lib/browser.dart b/lib/browser.dart index 033232d2..fe66108d 100644 --- a/lib/browser.dart +++ b/lib/browser.dart @@ -1,13 +1,16 @@ import 'dart:async' show Stream, StreamController; -import 'dart:convert' show JSON; import 'dart:html' show AnchorElement, window; import 'angel_route.dart'; final RegExp _hash = new RegExp(r'^#/'); +/// A variation of the [Router] support both hash routing and push state. abstract class BrowserRouter extends Router { + /// Fires whenever the active route changes. Fires `null` if none is selected (404). Stream get onRoute; + /// Set `hash` to true to use hash routing instead of push state. + /// `listen` as `true` will call `listen` after initialization. factory BrowserRouter({bool hash: false, bool listen: true, Route root}) { return hash ? new _HashRouter(listen: listen, root: root) @@ -16,8 +19,13 @@ abstract class BrowserRouter extends Router { BrowserRouter._([Route root]) : super(root); + /// Calls `goTo` on the [Route] matching `path`. void go(String path, [Map params]); + + /// Navigates to the given route. void goTo(Route route, [Map params]); + + /// Begins listen for location changes. void listen(); } diff --git a/lib/src/route.dart b/lib/src/route.dart index 12704061..4a4658ad 100644 --- a/lib/src/route.dart +++ b/lib/src/route.dart @@ -8,7 +8,7 @@ final RegExp _rgxStraySlashes = new RegExp(r'(^((\\/)|(/))+)|(((\\/)|(/))+$)'); final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); String _matcherify(String path, {bool expand: true}) { - var p = path.replaceAll(new RegExp(r'\/\*$'), "*").replaceAll('/', r'\/'); + var p = path.replaceAll(new RegExp(r'/\*$'), "*").replaceAll('/', r'\/'); if (expand) { var match = _param.firstMatch(p); @@ -44,6 +44,7 @@ String _pathify(String path) { return p; } +/// Represents a virtual location within an application. class Route { final List _children = []; final List _handlers = []; @@ -55,16 +56,36 @@ class Route { String _path; String _pathified; RegExp _resolver; - String _stub; + RegExp _stub; + + /// Set to `true` to print verbose debug output when interacting with this route. + bool debug; + + /// Contains any child routes attached to this one. List get children => new List.unmodifiable(_children); + + /// A `List` of arbitrary objects chosen to respond to this request. List get handlers => new List.unmodifiable(_handlers); + + /// A `RegExp` that matches requests to this route. RegExp get matcher => _matcher; + + /// The HTTP method this route is designated for. String get method => _method; + + /// The name of this route, if any. String get name => _name; + + /// The hierarchical parent of this route. Route get parent => _parent; + + /// The virtual path on which this route is mounted. String get path => _path; + + /// Arbitrary state attached to this route. final Extensible state = new Extensible(); + /// The [Route] at the top of the hierarchy this route is found in. Route get absoluteParent { Route result = this; @@ -94,8 +115,13 @@ class Route { return result; } + void _printDebug(msg) { + if (debug) print(msg); + } + Route(Pattern path, {Iterable children: const [], + this.debug: false, Iterable handlers: const [], method: "GET", String name: null}) { @@ -134,7 +160,11 @@ class Route { Iterable handlers: const [], method: "GET", String name: null}) { - final segments = path.toString().split('/').where((str) => str.isNotEmpty); + final segments = path + .toString() + .split('/') + .where((str) => str.isNotEmpty) + .toList(growable: false); Route result; if (segments.isEmpty) { @@ -142,11 +172,22 @@ class Route { children: children, handlers: handlers, method: method, name: name); } - for (final segment in segments) { - if (result == null) { - result = new Route(segment); - } else - result = result.child(segment); + for (int i = 0; i < segments.length; i++) { + final segment = segments[i]; + + if (i == segments.length - 1) { + if (result == null) { + result = new Route(segment); + } else { + result = result.child(segment); + } + } else { + if (result == null) { + result = new Route(segment, method: "*"); + } else { + result = result.child(segment, method: "*"); + } + } } result._children.addAll(children); @@ -157,6 +198,7 @@ class Route { return result; } + /// Combines the paths and matchers of two [Route] instances, and creates a new instance. factory Route.join(Route parent, Route child) { final String path1 = parent.path .replaceAll(_rgxStart, '') @@ -183,15 +225,20 @@ class Route { parent._children.add(route .._matcher = new RegExp('$pattern1$separator$pattern2') - .._parent = parent); + .._parent = parent + .._stub = child.matcher); + parent._printDebug( + 'Joined $path1 and $path2, produced stub ${route._stub.pattern}'); return route; } + /// Calls [addChild] on all given routes. List addAll(Iterable routes, {bool join: true}) { return routes.map((route) => addChild(route, join: join)).toList(); } + /// Adds the given route as a hierarchical child of this one. Route addChild(Route route, {bool join: true}) { Route created = join ? new Route.join(this, route) : route.._parent = this; return created; @@ -200,6 +247,7 @@ class Route { /// Assigns a name to this route. Route as(String name) => this.._name = name; + /// Creates a hierarchical child of this route with the given path. Route child(Pattern path, {Iterable children: const [], Iterable handlers: const [], @@ -223,6 +271,7 @@ class Route { return result.replaceAll("*", ""); } + /// Attempts to match a path against this route. Match match(String path) => matcher.firstMatch(path.replaceAll(_straySlashes, '')); @@ -256,17 +305,24 @@ class Route { yield routeMatch.group(i); } + /// Finds the first route available within this hierarchy that can respond to the given path. + /// + /// Can be used to navigate a route hierarchy like a file system. Route resolve(String path, {bool filter(Route route), String fullPath}) { final _filter = filter ?? (_) => true; final _fullPath = fullPath ?? path; if ((path.isEmpty || path == '.') && _filter(this)) { - return this; + // Try to find index + _printDebug('INDEX???'); + return children.firstWhere((r) => r.path.isEmpty, orElse: () => this); + } else if (path == '/') { + return absoluteParent.resolve(''); } else if (path.replaceAll(_straySlashes, '').isEmpty) { for (Route route in children) { final stub = route.path.replaceAll(this.path, ''); - if (stub == '/' || stub.isEmpty && _filter(route)) return route; + if ((stub == '/' || stub.isEmpty) && _filter(route)) return route; } if (_filter(this)) @@ -289,6 +345,11 @@ class Route { return this; } else { final segments = path.split('/').where((str) => str.isNotEmpty).toList(); + _printDebug('Segments: $segments on "/${this.path}"'); + + if (segments.isEmpty) { + return children.firstWhere((r) => r.path.isEmpty, orElse: () => this); + } if (segments[0] == '..') { if (parent != null) @@ -303,6 +364,8 @@ class Route { for (Route route in children) { final subPath = '${this.path}/${segments[0]}'; + _printDebug( + 'seg0: ${segments[0]}, stub: ${route._stub.pattern}, path: $path, route.path: ${route.path}, route.matcher: ${route.matcher.pattern}, this.matcher: ${matcher.pattern}'); if (route.match(subPath) != null || route._resolver.firstMatch(subPath) != null) { @@ -315,19 +378,22 @@ class Route { '/' + _fullPath.replaceAll(_straySlashes, '')); } + } else if (route._stub != null && route._stub.hasMatch(segments[0])) { + _printDebug('MAYBE STUB?'); + return route; } } // Try to match "subdirectory" for (Route route in children) { - print( + _printDebug( 'Trying to match subdir for $path; child ${route.path} on ${this.path}'); final match = route._parentResolver.firstMatch(path); if (match != null) { final subPath = path.replaceFirst(match[0], '').replaceAll(_straySlashes, ''); - print("Subdir path: $subPath"); + _printDebug("Subdir path: $subPath"); for (Route child in route.children) { final testPath = child.path @@ -341,15 +407,18 @@ class Route { return child; } } + + _printDebug('No subpath match: $subPath'); } else - print('Nope: $_parentResolver'); + _printDebug('Nope: $_parentResolver'); } + /* // Try to fill params for (Route route in children) { final params = parseParameters(_fullPath); final _filledPath = makeUri(params); - print( + _printDebug( 'Trying to match filled $_filledPath for ${route.path} on ${this.path}'); if ((route.match(_filledPath) != null || route._resolver.firstMatch(_filledPath) != null) && @@ -364,13 +433,13 @@ class Route { _filter(route)) return route; else { - print('Failed for ${route.matcher} when given $_filledPath'); + _printDebug('Failed for ${route.matcher} when given $_filledPath'); } - } + }*/ // Try to match the whole route, if nothing else works for (Route route in children) { - print( + _printDebug( 'Trying to match full $_fullPath for ${route.path} on ${this.path}'); if ((route.match(_fullPath) != null || route._resolver.firstMatch(_fullPath) != null) && @@ -385,7 +454,7 @@ class Route { _filter(route)) return route; else { - print('Failed for ${route.matcher} when given $_fullPath'); + _printDebug('Failed for ${route.matcher} when given $_fullPath'); } } diff --git a/lib/src/router.dart b/lib/src/router.dart index 2c840166..c85d5f33 100644 --- a/lib/src/router.dart +++ b/lib/src/router.dart @@ -1,17 +1,29 @@ import 'extensible.dart'; import 'route.dart'; +import 'routing_exception.dart'; final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); +/// An abstraction over complex [Route] trees. Use this instead of the raw API. :) class Router extends Extensible { + /// Set to `true` to print verbose debug output when interacting with this route. + bool debug = false; + /// Additional filters to be run on designated requests. Map requestMiddleware = {}; /// The single [Route] that serves as the root of the hierarchy. final Route root; + /// Provide a `root` to make this Router revolve around a pre-defined route. + /// Not recommended. Router([Route root]) : this.root = root ?? new Route('/', name: ''); + void _printDebug(msg) { + if (debug) + _printDebug(msg); + } + /// Adds a route that responds to the given path /// for requests with the given method (case-insensitive). /// Provide '*' as the method to respond to all methods. @@ -23,7 +35,62 @@ class Router extends Extensible { ..addAll(middleware ?? []) ..add(handler); - return root.child(path, handlers: handlers, method: method); + if (path is RegExp) { + return root.child(path, handlers: handlers, method: method); + } else if (path.toString().replaceAll(_straySlashes, '').isEmpty) { + return root.child(path.toString(), handlers: handlers, method: method); + } else { + var segments = path + .toString() + .split('/') + .where((str) => str.isNotEmpty) + .toList(growable: false); + Route result; + + if (segments.isEmpty) { + return new Route('/', handlers: handlers, method: method); + } else { + _printDebug('Want ${segments[0]}'); + result = resolve(segments[0]); + + if (result != null) { + if (segments.length > 1) { + _printDebug('Resolved: ${result} for "${segments[0]}"'); + segments = segments.skip(1).toList(growable: false); + + Route existing; + + do { + existing = result.resolve(segments[0]); + + if (existing != null) { + result = existing; + } + } while (existing != null); + } else throw new RoutingException("Cannot overwrite existing route '${segments[0]}'."); + } + } + + for (int i = 0; i < segments.length; i++) { + final segment = segments[i]; + + if (i == segments.length - 1) { + if (result == null) { + result = root.child(segment, handlers: handlers, method: method); + } else { + result = result.child(segment, handlers: handlers, method: method); + } + } else { + if (result == null) { + result = root.child(segment, method: "*"); + } else { + result = result.child(segment, method: "*"); + } + } + } + + return result; + } } /// Creates a visual representation of the route hierarchy and @@ -45,10 +112,8 @@ class Router extends Extensible { else buf.write("'${p.replaceAll(_straySlashes, '')}'"); - buf.write(' => '); - if (route.handlers.isNotEmpty) - buf.writeln('${route.handlers.length} handler(s)'); + buf.writeln(' => ${route.handlers.length} handler(s)'); else buf.writeln(); @@ -60,7 +125,7 @@ class Router extends Extensible { if (header != null && header.isNotEmpty) buf.writeln(header); dumpRoute(root); - (callback ?? print)(buf); + (callback ?? print)(buf.toString()); } /// Creates a route, and allows you to add child routes to it diff --git a/lib/src/routing_exception.dart b/lib/src/routing_exception.dart index 61574780..3d243bd4 100644 --- a/lib/src/routing_exception.dart +++ b/lib/src/routing_exception.dart @@ -1,6 +1,11 @@ +/// Represents an error in route configuration or navigation. abstract class RoutingException extends Exception { factory RoutingException(String message) => new _RoutingExceptionImpl(message); + + /// Occurs when trying to resolve the parent of a [Route] without a parent. factory RoutingException.orphan() => new _RoutingExceptionImpl("Tried to resolve path '..' on a route that has no parent."); + + /// Occurs when the user attempts to navigate to a non-existent route. factory RoutingException.noSuchRoute(String path) => new _RoutingExceptionImpl("Tried to navigate to non-existent route: '$path'."); } diff --git a/pubspec.yaml b/pubspec.yaml index f2e3eb9d..855e6c87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: angel_route description: A powerful, isomorphic routing library for Dart. -version: 1.0.0-dev +version: 1.0.0-dev+1 author: Tobe O homepage: https://github.com/angel-dart/angel_route dev_dependencies: diff --git a/test/route/with_params.dart b/test/route/with_params.dart index 24909c76..de0e06bc 100644 --- a/test/route/with_params.dart +++ b/test/route/with_params.dart @@ -2,55 +2,55 @@ import 'package:angel_route/angel_route.dart'; import 'package:test/test.dart'; main() { - final fooById = new Route.build('/foo/:id([0-9]+)', handlers: ['bar']); - final foo = fooById.parent; + final foo = new Router().root; + final fooById = foo.child(':id((?!bar)[0-9]+)', handlers: ['bar']); final bar = fooById.child('bar'); final baz = bar.child('//////baz//////', handlers: ['hello', 'world']); - final bazById = baz.child(':bazId'); + final bazById = baz.child(':bazId([A-Za-z]+)'); new Router(foo).dumpTree(); test('matching', () { expect(fooById.children.length, equals(1)); expect(fooById.handlers.length, equals(1)); expect(fooById.handlerSequence.length, equals(1)); - expect(fooById.path, equals('foo/:id')); - expect(fooById.match('/foo/2'), isNotNull); - expect(fooById.match('/foo/aaa'), isNull); + expect(fooById.path, equals(':id')); + expect(fooById.match('/2'), isNotNull); + expect(fooById.match('/aaa'), isNull); expect(fooById.match('/bar'), isNull); - expect(fooById.match('/foolish'), isNull); + expect(fooById.match('lish'), isNull); expect(fooById.parent, equals(foo)); expect(fooById.absoluteParent, equals(foo)); - expect(bar.path, equals('foo/:id/bar')); + expect(bar.path, equals(':id/bar')); expect(bar.children.length, equals(1)); expect(bar.handlers, isEmpty); expect(bar.handlerSequence.length, equals(1)); - expect(bar.match('/foo/2/bar'), isNotNull); + expect(bar.match('/2/bar'), isNotNull); expect(bar.match('/bar'), isNull); - expect(bar.match('/foo/a/bar'), isNull); + expect(bar.match('/a/bar'), isNull); expect(bar.parent, equals(fooById)); expect(baz.absoluteParent, equals(foo)); expect(baz.children.length, equals(1)); expect(baz.handlers.length, equals(2)); expect(baz.handlerSequence.length, equals(3)); - expect(baz.path, equals('foo/:id/bar/baz')); - expect(baz.match('/foo/2A/bar/baz'), isNull); - expect(baz.match('/foo/2/bar/baz'), isNotNull); - expect(baz.match('/foo/1337/bar/baz'), isNotNull); - expect(baz.match('/foo/bat/baz'), isNull); - expect(baz.match('/foo/bar/baz/1'), isNull); + expect(baz.path, equals(':id/bar/baz')); + expect(baz.match('/2A/bar/baz'), isNull); + expect(baz.match('/2/bar/baz'), isNotNull); + expect(baz.match('/1337/bar/baz'), isNotNull); + expect(baz.match('/bat/baz'), isNull); + expect(baz.match('/bar/baz/1'), isNull); expect(baz.parent, equals(bar)); expect(baz.absoluteParent, equals(foo)); }); test('hierarchy', () { - expect(fooById.resolve('/foo/2'), equals(fooById)); + expect(fooById.resolve('/2'), equals(fooById)); - expect(fooById.resolve('/foo/2/bar'), equals(bar)); - expect(fooById.resolve('/foo/bar'), isNull); - expect(fooById.resolve('/foo/a/bar'), isNull); - expect(fooById.resolve('foo/1337/bar/baz'), equals(baz)); + expect(fooById.resolve('/2/bar'), equals(bar)); + expect(fooById.resolve('/bar'), isNull); + expect(fooById.resolve('/a/bar'), isNull); + expect(fooById.resolve('1337/bar/baz'), equals(baz)); expect(bar.resolve('..'), equals(fooById)); @@ -67,11 +67,10 @@ main() { expect(baz.resolve('/2/bar'), equals(bar)); expect(baz.resolve('/1337/bar/baz'), equals(baz)); - expect(bar.resolve('/2/baz/e'), equals(bazById)); + expect(bar.resolve('/2/bar/baz/e'), equals(bazById)); expect(bar.resolve('baz/e'), equals(bazById)); - expect(bar.resolve('baz/e'), isNull); - expect(fooById.resolve('/foo/2/baz/e'), equals(bazById)); - expect(fooById.resolve('/foo/2/baz/2'), isNull); - expect(fooById.resolve('/foo/2a/baz/e'), isNull); + expect(fooById.resolve('/2/bar/baz/e'), equals(bazById)); + expect(fooById.resolve('/2/bar/baz/2'), isNull); + expect(fooById.resolve('/2a/bar/baz/e'), isNull); }); } diff --git a/test/router/all_tests.dart b/test/router/all_tests.dart index 28649bc0..fc41511b 100644 --- a/test/router/all_tests.dart +++ b/test/router/all_tests.dart @@ -7,7 +7,8 @@ final ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; main() { final router = new Router(); final indexRoute = router.get('/', () => ':)'); - final userById = router.delete('/user/:id/detail', (id) => num.parse(id)); + final fizz = router.post('/user/fizz', null); + final deleteUserById = router.delete('/user/:id/detail', (id) => num.parse(id)); Route lower; final letters = router.group('/letter///', (router) { @@ -19,9 +20,7 @@ main() { .child('/upper', handlers: [(String id) => id.toUpperCase()[0]]); }); - final fizz = router.post('/user/fizz', null); - - router.dumpTree(); + router.dumpTree(header: "ROUTER TESTS"); test('extensible', () { router['two'] = 2; @@ -34,14 +33,14 @@ main() { expect(lower.absoluteParent, equals(router.root)); expect(lower.parent.path, equals('letter/:id')); expect(lower.resolve('../upper').path, equals('letter/:id/upper')); - expect(lower.resolve('/user/34/detail'), equals(userById)); - expect(userById.resolve('../fizz'), equals(fizz)); + expect(lower.resolve('/user/34/detail'), equals(deleteUserById)); + expect(deleteUserById.resolve('../../fizz'), equals(fizz)); }); test('resolve', () { expect(router.resolve('/'), equals(indexRoute)); - expect(router.resolve('user/1337/detail'), equals(userById)); - expect(router.resolve('/user/1337/detail'), equals(userById)); + expect(router.resolve('user/1337/detail'), equals(deleteUserById)); + expect(router.resolve('/user/1337/detail'), equals(deleteUserById)); expect(router.resolve('letter/a/lower'), equals(lower)); expect(router.resolve('letter/2/lower'), isNull); }); diff --git a/uri.dart b/uri.dart deleted file mode 100644 index 75c95ccc..00000000 --- a/uri.dart +++ /dev/null @@ -1,5 +0,0 @@ -main() { - final uri = Uri.parse('/foo'); - print(uri); - print(uri.resolve('/bar')); -} \ No newline at end of file diff --git a/web/shared/basic.dart b/web/shared/basic.dart index b35c68eb..e897e3a6 100644 --- a/web/shared/basic.dart +++ b/web/shared/basic.dart @@ -23,6 +23,7 @@ basic(BrowserRouter router) { router.get('a', 'a handler'); router.group('b', (router) { + print(router.root); router.get('a', 'b/a handler'); router.get('b', 'b/b handler', middleware: ['b/b middleware']); }, middleware: ['b middleware']);