diff --git a/.gitignore b/.gitignore
index 68b3a77c..efa8ebc7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,5 +25,49 @@ doc/api/
# Don't commit pubspec lock file
# (Library packages only! Remove pattern if developing an application package)
pubspec.lock
+### JetBrains template
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff:
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+
+# Sensitive or high-churn files:
+.idea/dataSources.ids
+.idea/dataSources.xml
+.idea/dataSources.local.xml
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+
+# Gradle:
+.idea/gradle.xml
+.idea/libraries
+
+# Mongo Explorer plugin:
+.idea/mongoSettings.xml
+
+## File-based project format:
+*.iws
+
+## Plugin-specific files:
+
+# IntelliJ
+/out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
-.idea
\ No newline at end of file
diff --git a/.idea/angel_route.iml b/.idea/angel_route.iml
new file mode 100644
index 00000000..1aa3806d
--- /dev/null
+++ b/.idea/angel_route.iml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..eac82ac0
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ General
+
+
+ XPath
+
+
+
+
+ AngularJS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..51fa8762
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/All_Tests.xml b/.idea/runConfigurations/All_Tests.xml
new file mode 100644
index 00000000..a824b209
--- /dev/null
+++ b/.idea/runConfigurations/All_Tests.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 57d0b0b5..4c387972 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,137 @@
# angel_route
-Advanced routing API, supports both server and browser.
\ No newline at end of file
+A powerful, isomorphic routing library for Dart.
+
+This API is a huge improvement over the original [Angel](https://github.com/angel-dart/angel)
+routing system, and thus deserves to be its own individual project.
+
+`angel_route` exposes a routing system that takes the shape of a tree. This tree structure
+can be easily navigated, in a fashion somewhat similar to a filesystem. The `Router` API
+is a very straightforward interface that allows for your code to take a shape similar to
+the route tree. Users of Laravel and Express will be very happy.
+
+`angel_route` does not require the use of [Angel](https://github.com/angel-dart/angel),
+and has no dependencies. Thus, it can be used in any application, regardless of
+framework. This includes Web apps, Flutter apps, CLI apps, and smaller servers which do
+not need all the features of the Angel framework.
+
+# Contents
+
+* [Examples](#examples)
+ * [Routing](#routing)
+ * [Tree Hierarchy and Path Resolution](#hierarchy)
+* [In the Browser](#in-the-browser)
+* [Route State](#route-state)
+* [Route Parameters](#route-parameters)
+
+# Examples
+
+## Routing
+If you use [Angel](https://github.com/angel-dart/angel), every `Angel` instance is
+a `Router` in itself.
+
+```dart
+
+main() {
+ final router = new Router();
+
+ router.get('/users', () {});
+
+ router.post('/users/:id/timeline', (String id) {});
+
+ router.get('/square_root/:id([0-9]+)', (String id) {
+ final n = num.parse(id);
+ return {'result': pow(n, 2) };
+ });
+
+ router.group('/show/:id', (router) {
+ router.get('/reviews', (id) {
+ return someQuery(id).reviews;
+ });
+
+ // Optionally restrict params to a RegExp
+ router.get('/reviews/:reviewId([A-Za-z0-9_]+)', (id, reviewId) {
+ return someQuery(id).reviews.firstWhere(
+ (r) => r.id == reviewId);
+ });
+ }, before: [put, middleware, here]);
+}
+```
+
+The default `Router` does not give any notification of routes being changed, because
+there is no inherent stream of URL's for it to listen to. This is good, because a server
+needs a lot of flexibility with which to handle requests.
+
+## Hierarchy
+
+```dart
+main() {
+ final foo = new Route('/');
+ final bar = foo.child('bar');
+ final baz = foo.child('baz');
+
+ final a = bar.child('a');
+
+ /*
+ * Relative paths:
+ * a.resolve('../baz') = baz;
+ * bar.resolve('a') = a;
+ *
+ * Absolute paths:
+ * a.resolve('/bar/a') = a;
+ */
+}
+```
+
+```dart
+main() {
+ final router = new Router();
+
+ router.group('/user/:id', (router) {
+ router.get('/balance', (id) async {
+ final user = await someQuery(id);
+ return user.balance;
+ });
+ });
+}
+```
+
+See [the tests](test/route/no_params.dart) for good examples.
+
+# In the Browser
+Supports both hashed routes and pushState. The `BrowserRouter` interface exposes
+a `Stream onRoute`, which can be listened to for changes. It will fire `null`
+whenever no route is matched.
+
+```dart
+main() {
+
+}
+```
+
+`angel_route` will also automatically intercept `` elements and redirect them to
+your routes.
+
+To prevent this for a given anchor, do any of the following:
+ * Do not provide an `href`
+ * Provide a `download` or `target` attribute on the element
+ * Set `rel="external"`
+
+# Route State
+Routes can have state via the `Extensible` class, which is a simple proxy over a `Map`.
+This does not require reflection, and can be used in all Dart environments.
+
+```dart
+main() {
+ final router = new BrowserRouter();
+ // ..
+ router.onRoute.listen((route) {
+ if (route == null)
+ throw 404;
+ else route.state.foo = 'bar';
+ });
+}
+```
+
+# Route Parameters
+Routes can have parameters, as seen in the above examples.
+If a parameter is a numeber, then it will automatically be parsed.
\ No newline at end of file
diff --git a/lib/angel_route.dart b/lib/angel_route.dart
index 37565e32..8dadd5b7 100644
--- a/lib/angel_route.dart
+++ b/lib/angel_route.dart
@@ -1,3 +1,5 @@
library angel_route;
-export 'src/route.dart';
\ No newline at end of file
+export 'src/route.dart';
+export 'src/router.dart';
+export 'src/routing_exception.dart';
\ No newline at end of file
diff --git a/lib/browser.dart b/lib/browser.dart
new file mode 100644
index 00000000..033232d2
--- /dev/null
+++ b/lib/browser.dart
@@ -0,0 +1,123 @@
+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'^#/');
+
+abstract class BrowserRouter extends Router {
+ Stream get onRoute;
+
+ factory BrowserRouter({bool hash: false, bool listen: true, Route root}) {
+ return hash
+ ? new _HashRouter(listen: listen, root: root)
+ : new _PushStateRouter(listen: listen, root: root);
+ }
+
+ BrowserRouter._([Route root]) : super(root);
+
+ void go(String path, [Map params]);
+ void goTo(Route route, [Map params]);
+ void listen();
+}
+
+class _BrowserRouterImpl extends Router implements BrowserRouter {
+ Route _current;
+ StreamController _onRoute = new StreamController();
+ Route get currentRoute => _current;
+
+ @override
+ Stream get onRoute => _onRoute.stream;
+
+ _BrowserRouterImpl({bool listen, Route root}) : super(root) {
+ if (listen) this.listen();
+ prepareAnchors();
+ }
+
+ @override
+ void go(String path, [Map params]) {
+ final resolved = resolve(path);
+
+ if (resolved != null)
+ goTo(resolved, params);
+ else
+ throw new RoutingException.noSuchRoute(path);
+ }
+
+ void prepareAnchors() {
+ final anchors = window.document.querySelectorAll('a:not([dynamic])');
+
+ for (final AnchorElement $a in anchors) {
+ if ($a.attributes.containsKey('href') &&
+ !$a.attributes.containsKey('download') &&
+ !$a.attributes.containsKey('target') &&
+ $a.attributes['rel'] != 'external') {
+ $a.onClick.listen((e) {
+ e.preventDefault();
+ go($a.attributes['href']);
+ });
+ }
+
+ $a.attributes['dynamic'] = 'true';
+ }
+ }
+}
+
+class _HashRouter extends _BrowserRouterImpl {
+ _HashRouter({bool listen, Route root}) : super(listen: listen, root: root) {
+ if (listen) this.listen();
+ }
+
+ @override
+ void goTo(Route route, [Map params]) {
+ route.state.properties.addAll(params ?? {});
+ window.location.hash = '#/${route.makeUri(params)}';
+ _onRoute.add(route);
+ }
+
+ @override
+ void listen() {
+ window.onHashChange.listen((_) {
+ final path = window.location.hash.replaceAll(_hash, '');
+ final resolved = resolve(path);
+
+ if (resolved == null || (path.isEmpty && resolved == root)) {
+ _onRoute.add(_current = null);
+ } else if (resolved != null && resolved != _current) {
+ goTo(resolved);
+ }
+ });
+ }
+}
+
+class _PushStateRouter extends _BrowserRouterImpl {
+ _PushStateRouter({bool listen, Route root})
+ : super(listen: listen, root: root) {
+ if (listen) this.listen();
+ }
+
+ @override
+ void goTo(Route route, [Map params]) {
+ window.history.pushState(
+ {'path': route.path, 'params': params ?? {}, 'properties': properties},
+ route.name ?? route.path,
+ route.makeUri(params));
+ _onRoute.add(_current = route..state.properties.addAll(params ?? {}));
+ }
+
+ @override
+ void listen() {
+ window.onPopState.listen((e) {
+ if (e.state is Map && e.state.containsKey('path')) {
+ final resolved = resolve(e.state['path']);
+
+ if (resolved != _current) {
+ properties.addAll(e.state['properties'] ?? {});
+ _onRoute.add(_current = resolved
+ ..state.properties.addAll(e.state['params'] ?? {}));
+ }
+ } else
+ _onRoute.add(_current = null);
+ });
+ }
+}
diff --git a/lib/src/extensible.dart b/lib/src/extensible.dart
new file mode 100644
index 00000000..a96ec806
--- /dev/null
+++ b/lib/src/extensible.dart
@@ -0,0 +1,31 @@
+final RegExp _equ = new RegExp(r'=$');
+final RegExp _sym = new RegExp(r'Symbol\("([^"]+)"\)');
+
+/// Supports accessing members of a Map as though they were actual members.
+///
+/// No longer requires reflection. :)
+@proxy
+class Extensible {
+ /// A set of custom properties that can be assigned to the server.
+ ///
+ /// Useful for configuration and extension.
+ Map properties = {};
+
+ noSuchMethod(Invocation invocation) {
+ if (invocation.memberName != null) {
+ String name = _sym.firstMatch(invocation.memberName.toString()).group(1);
+
+ if (invocation.isMethod) {
+ return Function.apply(properties[name], invocation.positionalArguments,
+ invocation.namedArguments);
+ } else if (invocation.isGetter) {
+ return properties[name];
+ } else if (invocation.isSetter) {
+ return properties[name.replaceAll(_equ, '')] =
+ invocation.positionalArguments.first;
+ }
+ }
+
+ super.noSuchMethod(invocation);
+ }
+}
diff --git a/lib/src/route.dart b/lib/src/route.dart
index 8fec987c..4287a8a8 100644
--- a/lib/src/route.dart
+++ b/lib/src/route.dart
@@ -1,66 +1,160 @@
-final RegExp _rgxEnd = new RegExp(r'\$');
-final RegExp _rgxStart = new RegExp(r'\^');
+import 'extensible.dart';
+import 'routing_exception.dart';
+
+final RegExp _param = new RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?');
+final RegExp _rgxEnd = new RegExp(r'\$+$');
+final RegExp _rgxStart = new RegExp(r'^\^+');
+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'\/');
+
+ if (expand) {
+ var match = _param.firstMatch(p);
+
+ while (match != null) {
+ if (match.group(3) == null)
+ p = p.replaceAll(match[0], '([^\/]+)');
+ else
+ p = p.replaceAll(match[0], '(${match[3]})');
+ match = _param.firstMatch(p);
+ }
+ }
+
+ p = p.replaceAll(new RegExp('\\*'), '.*');
+
+ p = '^$p\$';
+ return p;
+}
+
+String _pathify(String path) {
+ var p = path.replaceAll(_straySlashes, '');
+
+ Map replace = {};
+
+ for (Match match in _param.allMatches(p)) {
+ if (match[3] != null) replace[match[0]] = ':${match[1]}';
+ }
+
+ replace.forEach((k, v) {
+ p = p.replaceAll(k, v);
+ });
+
+ return p;
+}
+
class Route {
final List _children = [];
final List _handlers = [];
RegExp _matcher;
+ String _name;
Route _parent;
String _path;
+ String _pathified;
+ RegExp _resolver;
List get children => new List.unmodifiable(_children);
List get handlers => new List.unmodifiable(_handlers);
RegExp get matcher => _matcher;
final String method;
- final String name;
+ String get name => _name;
Route get parent => _parent;
String get path => _path;
+ final Extensible state = new Extensible();
+
+ Route get absoluteParent {
+ Route result = this;
+
+ while (result.parent != null) result = result.parent;
+
+ return result;
+ }
+
+ /// Backtracks up the hierarchy, and builds
+ /// a sequential list of all handlers from both
+ /// this route, and every found parent route.
+ ///
+ /// The resulting list puts handlers from routes
+ /// higher in the tree at lower indices. Thus,
+ /// this can be used in a routing-enabled application
+ /// to evaluate multiple middleware on a single route,
+ /// and apply them to all children.
+ List get handlerSequence {
+ final result = [];
+ var r = this;
+
+ while (r != null) {
+ result.insertAll(0, r.handlers);
+ r = r.parent;
+ }
+
+ return result;
+ }
Route(Pattern path,
{Iterable children: const [],
Iterable handlers: const [],
this.method: "GET",
- this.name: null}) {
+ String name: null}) {
if (children != null) _children.addAll(children);
if (handlers != null) _handlers.addAll(handlers);
+ _name = name;
if (path is RegExp) {
_matcher = path;
_path = path.pattern;
} else {
- _matcher = new RegExp(_path = path
- .toString()
- .replaceAll(_straySlashes, '')
- .replaceAll(new RegExp(r'\/\*$'), "*")
- .replaceAll(new RegExp('\/'), r'\/')
- .replaceAll(new RegExp(':[a-zA-Z_]+'), '([^\/]+)')
- .replaceAll(new RegExp('\\*'), '.*'));
+ _matcher = new RegExp(
+ _matcherify(path.toString().replaceAll(_straySlashes, '')));
+ _path = _pathified = _pathify(path.toString());
+ _resolver = new RegExp(_matcherify(
+ path.toString().replaceAll(_straySlashes, ''),
+ expand: false));
}
}
factory Route.join(Route parent, Route child) {
- final String path1 = parent.path.replaceAll(_straySlashes, '');
- final String path2 = child.path.replaceAll(_straySlashes, '');
- final String pattern1 = parent.matcher.pattern.replaceAll(_rgxEnd, '');
- final String pattern2 = child.matcher.pattern.replaceAll(_rgxStart, '');
+ final String path1 = parent.path
+ .replaceAll(_rgxStart, '')
+ .replaceAll(_rgxEnd, '')
+ .replaceAll(_straySlashes, '');
+ final String path2 = child.path
+ .replaceAll(_rgxStart, '')
+ .replaceAll(_rgxEnd, '')
+ .replaceAll(_straySlashes, '');
+ final String pattern1 = parent.matcher.pattern
+ .replaceAll(_rgxEnd, '')
+ .replaceAll(_rgxStraySlashes, '');
+ final String pattern2 = child.matcher.pattern
+ .replaceAll(_rgxStart, '')
+ .replaceAll(_rgxStraySlashes, '');
- final route = new Route(new RegExp('$pattern1/$pattern2'),
+ final route = new Route('$path1/$path2',
children: child.children,
handlers: child.handlers,
method: child.method,
name: child.name);
+ String separator = (pattern1.isEmpty || pattern1 == '^') ? '' : '\\/';
+
return route
- ..parent = parent
- .._path = '$path1/$path2';
+ .._matcher = new RegExp('$pattern1$separator$pattern2')
+ .._parent = parent;
+ }
+
+ List addAll(Iterable routes, {bool join: true}) {
+ return routes.map((route) => addChild(route, join: join)).toList();
}
Route addChild(Route route, {bool join: true}) {
- Route created = join ? new Route.join(this, route) : route;
+ Route created = join ? new Route.join(this, route) : route.._parent = this;
_children.add(created);
return created;
}
+ /// Assigns a name to this route.
+ Route as(String name) => this.._name = name;
+
Route child(Pattern path,
{Iterable children: const [],
Iterable handlers: const [],
@@ -71,28 +165,109 @@ class Route {
return addChild(route);
}
- Match match(String path) => matcher.firstMatch(path);
+ /// Generates a URI to this route with the given parameters.
+ String makeUri([Map params]) {
+ String result = _pathified;
+ if (params != null) {
+ for (String key in (params.keys)) {
+ result = result.replaceAll(
+ new RegExp(":$key" + r"\??"), params[key].toString());
+ }
+ }
- Route resolve(String path) {
- if (path.isEmpty ||
- path == '.' ||
- path.replaceAll(_straySlashes, '').isEmpty) {
+ return result.replaceAll("*", "");
+ }
+
+ Match match(String path) =>
+ matcher.firstMatch(path.replaceAll(_straySlashes, ''));
+
+ /// Extracts route parameters from a given path.
+ Map parseParameters(String requestPath) {
+ Map result = {};
+
+ Iterable values = _parseParameters(requestPath.replaceAll(_straySlashes, ''));
+ Iterable matches = _param.allMatches(
+ _pathified.replaceAll(new RegExp('\/'), r'\/'));
+ for (int i = 0; i < matches.length; i++) {
+ Match match = matches.elementAt(i);
+ String paramName = match.group(1);
+ String value = values.elementAt(i);
+ num numValue = num.parse(value, (_) => double.NAN);
+ if (!numValue.isNaN)
+ result[paramName] = numValue;
+ else
+ result[paramName] = value;
+ }
+
+ return result;
+ }
+
+ _parseParameters(String requestPath) sync* {
+ Match routeMatch = matcher.firstMatch(requestPath);
+ for (int i = 1; i <= routeMatch.groupCount; i++)
+ yield routeMatch.group(i);
+ }
+
+ Route resolve(String path, [bool filter(Route route)]) {
+ final _filter = filter ?? (_) => true;
+
+ if (path.isEmpty || path == '.' && _filter(this)) {
return this;
+ } 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 (_filter(this))
+ return this;
+ else
+ return null;
+ } else if (path == '..') {
+ if (parent != null)
+ return parent;
+ else
+ throw new RoutingException.orphan();
+ } else if (path.startsWith('/') &&
+ path.length > 1 &&
+ path[1] != '/' &&
+ absoluteParent != null) {
+ return absoluteParent.resolve(path.substring(1), _filter);
} else {
final segments = path.split('/');
+ if (segments[0] == '..') {
+ if (parent != null)
+ return parent.resolve(segments.skip(1).join('/'), _filter);
+ else
+ throw new RoutingException.orphan();
+ }
+
for (Route route in children) {
final subPath = '${this.path}/${segments[0]}';
- if (route.match(subPath) != null) {
- if (segments.length == 1)
+ if (route.match(subPath) != null ||
+ route._resolver.firstMatch(subPath) != null) {
+ if (segments.length == 1 && _filter(route))
return route;
else {
- return route.resolve(segments.skip(1).join('/'));
+ return route.resolve(segments.skip(1).join('/'), _filter);
}
}
}
+ // Try to match the whole route, if nothing else works
+ for (Route route in children) {
+ if ((route.match(path) != null ||
+ route._resolver.firstMatch(path) != null) &&
+ _filter(route))
+ return route;
+ else if ((route.match('/$path') != null ||
+ route._resolver.firstMatch('/$path') != null) &&
+ _filter(route)) return route;
+ }
+
return null;
}
}
diff --git a/lib/src/router.dart b/lib/src/router.dart
new file mode 100644
index 00000000..6dc20d5d
--- /dev/null
+++ b/lib/src/router.dart
@@ -0,0 +1,168 @@
+import 'extensible.dart';
+import 'route.dart';
+
+final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)');
+
+class Router extends Extensible {
+ /// Additional filters to be run on designated requests.
+ Map requestMiddleware = {};
+
+ /// The single [Route] that serves as the root of the hierarchy.
+ final Route root;
+
+ Router([Route root]) : this.root = root ?? new Route('/', name: '');
+
+ /// 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.
+ Route addRoute(String method, Pattern path, Object handler,
+ {List middleware}) {
+ List handlers = [];
+
+ handlers
+ ..addAll(middleware ?? [])
+ ..add(handler);
+
+ return root.child(path, handlers: handlers, method: method);
+ }
+
+ /// Creates a visual representation of the route hierarchy and
+ /// passes it to a callback. If none is provided, `print` is called.
+ void dumpTree(
+ {callback(String tree), header: 'Dumping route tree:', tab: ' '}) {
+ var tabs = 0;
+ final buf = new StringBuffer();
+
+ void dumpRoute(Route route, {String replace: null}) {
+ for (var i = 0; i < tabs; i++) buf.write(tab);
+ buf.write('- ${route.method} ');
+
+ final p =
+ replace != null ? route.path.replaceAll(replace, '') : route.path;
+
+ if (p.isEmpty)
+ buf.write("'/'");
+ else
+ buf.write("'${p.replaceAll(_straySlashes, '')}'");
+
+ buf.write(' => ');
+
+ if (route.handlers.isNotEmpty)
+ buf.writeln('${route.handlers.length} handler(s)');
+ else
+ buf.writeln();
+
+ tabs++;
+ route.children.forEach((r) => dumpRoute(r, replace: route.path));
+ tabs--;
+ }
+
+ if (header != null && header.isNotEmpty) buf.writeln(header);
+
+ dumpRoute(root);
+ (callback ?? print)(buf);
+ }
+
+ /// Creates a route, and allows you to add child routes to it
+ /// via a [Router] instance.
+ ///
+ /// Returns the created route.
+ /// You can also register middleware within the router.
+ Route group(Pattern path, void callback(Router router),
+ {Iterable middleware: const [],
+ String method: "*",
+ String name: null,
+ String namespace: null}) {
+ final route =
+ root.child(path, handlers: middleware, method: method, name: name);
+ final router = new Router(route);
+ callback(router);
+
+ // Let's copy middleware, heeding the optional middleware namespace.
+ String middlewarePrefix = namespace != null ? "$namespace." : "";
+
+ Map copiedMiddleware = new Map.from(router.requestMiddleware);
+ for (String middlewareName in copiedMiddleware.keys) {
+ requestMiddleware["$middlewarePrefix$middlewareName"] =
+ copiedMiddleware[middlewareName];
+ }
+
+ return route;
+ }
+
+ /// Assigns a middleware to a name for convenience.
+ registerMiddleware(String name, middleware) {
+ requestMiddleware[name] = middleware;
+ }
+
+ /// Finds the first [Route] that matches the given path.
+ ///
+ /// You can pass an additional filter to determine which
+ /// routes count as matches.
+ Route resolve(String path, [bool filter(Route route)]) =>
+ root.resolve(path, filter);
+
+ /// Incorporates another [Router]'s routes into this one's.
+ ///
+ /// If `hooked` is set to `true` and a [Service] is provided,
+ /// then that service will be wired to a [HookedService] proxy.
+ /// If a `namespace` is provided, then any middleware
+ /// from the provided [Router] will be prefixed by that namespace,
+ /// with a dot.
+ /// For example, if the [Router] has a middleware 'y', and the `namespace`
+ /// is 'x', then that middleware will be available as 'x.y' in the main router.
+ /// These namespaces can be nested.
+ void use(Pattern path, Router router,
+ {bool hooked: true, String namespace: null}) {
+ // Let's copy middleware, heeding the optional middleware namespace.
+ String middlewarePrefix = namespace != null ? "$namespace." : "";
+
+ Map copiedMiddleware = new Map.from(router.requestMiddleware);
+ for (String middlewareName in copiedMiddleware.keys) {
+ requestMiddleware["$middlewarePrefix$middlewareName"] =
+ copiedMiddleware[middlewareName];
+ }
+
+ root.addChild(router.root);
+ }
+
+ /// Adds a route that responds to any request matching the given path.
+ Route all(Pattern path, Object handler, {List middleware}) {
+ return addRoute('*', path, handler, middleware: middleware);
+ }
+
+ /// Adds a route that responds to a DELETE request.
+ Route delete(Pattern path, Object handler, {List middleware}) {
+ return addRoute('DELETE', path, handler, middleware: middleware);
+ }
+
+ /// Adds a route that responds to a GET request.
+ Route get(Pattern path, Object handler, {List middleware}) {
+ return addRoute('GET', path, handler, middleware: middleware);
+ }
+
+ /// Adds a route that responds to a HEAD request.
+ Route head(Pattern path, Object handler, {List middleware}) {
+ return addRoute('HEAD', path, handler, middleware: middleware);
+ }
+
+ /// Adds a route that responds to a OPTIONS request.
+ Route options(Pattern path, Object handler, {List middleware}) {
+ return addRoute('OPTIONS', path, handler, middleware: middleware);
+ }
+
+ /// Adds a route that responds to a POST request.
+ Route post(Pattern path, Object handler, {List middleware}) {
+ return addRoute('POST', path, handler, middleware: middleware);
+ }
+
+ /// Adds a route that responds to a PATCH request.
+ Route patch(Pattern path, Object handler, {List middleware}) {
+ return addRoute('PATCH', path, handler, middleware: middleware);
+ }
+
+ /// Adds a route that responds to a PUT request.
+ Route put(Pattern path, Object handler, {List middleware}) {
+ return addRoute('PUT', path, handler, middleware: middleware);
+ }
+}
diff --git a/lib/src/routing_exception.dart b/lib/src/routing_exception.dart
new file mode 100644
index 00000000..61574780
--- /dev/null
+++ b/lib/src/routing_exception.dart
@@ -0,0 +1,14 @@
+abstract class RoutingException extends Exception {
+ factory RoutingException(String message) => new _RoutingExceptionImpl(message);
+ factory RoutingException.orphan() => new _RoutingExceptionImpl("Tried to resolve path '..' on a route that has no parent.");
+ factory RoutingException.noSuchRoute(String path) => new _RoutingExceptionImpl("Tried to navigate to non-existent route: '$path'.");
+}
+
+class _RoutingExceptionImpl implements RoutingException {
+ final String message;
+
+ _RoutingExceptionImpl(this.message);
+
+ @override
+ String toString() => message;
+}
\ No newline at end of file
diff --git a/pubspec.yaml b/pubspec.yaml
index c0e5e021..f2e3eb9d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,8 @@
name: angel_route
-description: Advanced routing API, supports both server and browser.
+description: A powerful, isomorphic routing library for Dart.
version: 1.0.0-dev
author: Tobe O
homepage: https://github.com/angel-dart/angel_route
dev_dependencies:
+ browser: ">=0.10.0 < 0.11.0"
test: ">=0.12.15 <0.13.0"
\ No newline at end of file
diff --git a/test/all_tests.dart b/test/all_tests.dart
index 2f0c9266..e4514b2d 100644
--- a/test/all_tests.dart
+++ b/test/all_tests.dart
@@ -1,8 +1,8 @@
-import '../lib/angel_route.dart';
+import 'package:test/test.dart';
+import 'route/all_tests.dart' as route;
+import 'router/all_tests.dart' as router;
main() {
- final foo = new Route('/foo');
- final bar = foo.child('/bar');
- print(foo.path);
- print(bar.path);
+ group('route', route.main);
+ group('router', router.main);
}
\ No newline at end of file
diff --git a/test/packages b/test/packages
new file mode 120000
index 00000000..a16c4050
--- /dev/null
+++ b/test/packages
@@ -0,0 +1 @@
+../packages
\ No newline at end of file
diff --git a/test/route/all_tests.dart b/test/route/all_tests.dart
new file mode 100644
index 00000000..14a4988b
--- /dev/null
+++ b/test/route/all_tests.dart
@@ -0,0 +1,10 @@
+import 'package:test/test.dart';
+import 'no_params.dart' as no_params;
+import 'parse_params.dart' as parse_params;
+import 'with_params.dart' as with_params;
+
+main() {
+ group('parse params', parse_params.main);
+ group('no params', no_params.main);
+ group('with params', with_params.main);
+}
\ No newline at end of file
diff --git a/test/route/no_params.dart b/test/route/no_params.dart
new file mode 100644
index 00000000..f14b8cd7
--- /dev/null
+++ b/test/route/no_params.dart
@@ -0,0 +1,57 @@
+import 'package:angel_route/angel_route.dart';
+import 'package:test/test.dart';
+
+main() {
+ final foo = new Route('/foo', handlers: ['bar']);
+ final bar = foo.child('/bar');
+ final baz = bar.child('//////baz//////', handlers: ['hello', 'world']);
+
+ test('matching', () {
+ expect(foo.children.length, equals(1));
+ expect(foo.handlers.length, equals(1));
+ expect(foo.handlerSequence.length, equals(1));
+ expect(foo.path, equals('foo'));
+ expect(foo.match('/foo'), isNotNull);
+ expect(foo.match('/bar'), isNull);
+ expect(foo.match('/foolish'), isNull);
+ expect(foo.parent, isNull);
+ expect(foo.absoluteParent, equals(foo));
+
+ expect(bar.path, equals('foo/bar'));
+ expect(bar.children.length, equals(1));
+ expect(bar.handlers, isEmpty);
+ expect(bar.handlerSequence.length, equals(1));
+ expect(bar.match('/foo/bar'), isNotNull);
+ expect(bar.match('/bar'), isNull);
+ expect(bar.match('/foo/bar/2'), isNull);
+ expect(bar.parent, equals(foo));
+ expect(baz.absoluteParent, equals(foo));
+
+ expect(baz.children, isEmpty);
+ expect(baz.handlers.length, equals(2));
+ expect(baz.handlerSequence.length, equals(3));
+ expect(baz.path, equals('foo/bar/baz'));
+ expect(baz.match('/foo/bar/baz'), isNotNull);
+ expect(baz.match('/foo/bat/baz'), isNull);
+ expect(baz.match('/foo/bar/baz/1'), isNull);
+ expect(baz.parent, equals(bar));
+ expect(baz.absoluteParent, equals(foo));
+ });
+
+ test('hierarchy', () {
+ expect(foo.resolve('bar'), equals(bar));
+ expect(foo.resolve('bar/baz'), equals(baz));
+
+ expect(bar.resolve('..'), equals(foo));
+ expect(bar.resolve('/bar/baz'), equals(baz));
+ expect(bar.resolve('../bar'), equals(bar));
+
+ expect(baz.resolve('..'), equals(bar));
+ expect(baz.resolve('../..'), equals(foo));
+ expect(baz.resolve('../baz'), equals(baz));
+ expect(baz.resolve('../../bar'), equals(bar));
+ expect(baz.resolve('../../bar/baz'), equals(baz));
+ expect(baz.resolve('/bar'), equals(bar));
+ expect(baz.resolve('/bar/baz'), equals(baz));
+ });
+}
diff --git a/test/route/packages b/test/route/packages
new file mode 120000
index 00000000..4b727bf6
--- /dev/null
+++ b/test/route/packages
@@ -0,0 +1 @@
+../../packages
\ No newline at end of file
diff --git a/test/route/parse_params.dart b/test/route/parse_params.dart
new file mode 100644
index 00000000..58ec0adc
--- /dev/null
+++ b/test/route/parse_params.dart
@@ -0,0 +1,26 @@
+import 'package:angel_route/angel_route.dart';
+import 'package:test/test.dart';
+
+p(x) {
+ print(x);
+ return x;
+}
+
+main() {
+ final foo = new Route('/foo/:id');
+ final bar = foo.child('/bar/:barId/baz');
+
+ test('make uri', () {
+ expect(p(foo.makeUri({'id': 1337})), equals('foo/1337'));
+ expect(p(bar.makeUri({'id': 1337, 'barId': 12})),
+ equals('foo/1337/bar/12/baz'));
+ });
+
+ test('parse', () {
+ final fooParams = foo.parseParameters('foo/1337/////');
+ expect(p(fooParams), equals({'id': 1337}));
+
+ final barParams = bar.parseParameters('/////foo/1337/bar/12/baz');
+ expect(p(barParams), equals({'id': 1337, 'barId': 12}));
+ });
+}
diff --git a/test/route/with_params.dart b/test/route/with_params.dart
new file mode 100644
index 00000000..603f7714
--- /dev/null
+++ b/test/route/with_params.dart
@@ -0,0 +1,3 @@
+main() {
+
+}
\ No newline at end of file
diff --git a/test/router/all_tests.dart b/test/router/all_tests.dart
new file mode 100644
index 00000000..aef587e4
--- /dev/null
+++ b/test/router/all_tests.dart
@@ -0,0 +1,42 @@
+import 'package:angel_route/angel_route.dart';
+import 'package:test/test.dart';
+
+final ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+
+main() {
+ final router = new Router();
+ final indexRoute = router.get('/', () => ':)');
+ final userById = router.delete('/user/:id/detail', (id) => num.parse(id));
+
+ Route lower;
+ final letters = router.group('/letter///', (router) {
+ lower = router
+ .get('/:id([A-Za-z])', (id) => ABC.indexOf(id[0]))
+ .child('////lower', handlers: [(String id) => id.toLowerCase()[0]]);
+
+ lower.parent
+ .child('/upper', handlers: [(String id) => id.toUpperCase()[0]]);
+ });
+
+ router.dumpTree();
+
+ test('extensible', () {
+ router.two = 2;
+ expect(router.properties['two'], equals(2));
+ });
+
+ test('hierarchy', () {
+ 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));
+ });
+
+ 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('letter/a/lower'), equals(lower));
+ expect(router.resolve('letter/2/lower'), isNull);
+ });
+}
diff --git a/test/router/packages b/test/router/packages
new file mode 120000
index 00000000..4b727bf6
--- /dev/null
+++ b/test/router/packages
@@ -0,0 +1 @@
+../../packages
\ No newline at end of file
diff --git a/web/hash/basic.dart b/web/hash/basic.dart
new file mode 100644
index 00000000..b5ba5e99
--- /dev/null
+++ b/web/hash/basic.dart
@@ -0,0 +1,5 @@
+import 'package:angel_route/browser.dart';
+import '../shared/basic.dart';
+
+main() => basic(new BrowserRouter(hash: true));
+
diff --git a/web/hash/basic.html b/web/hash/basic.html
new file mode 100644
index 00000000..e2e0e86b
--- /dev/null
+++ b/web/hash/basic.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Hash Router
+
+
+
+
+No Active Route
+Handler Sequence:
+
+
+
+
+
\ No newline at end of file
diff --git a/web/push_state/basic.dart b/web/push_state/basic.dart
new file mode 100644
index 00000000..d889a70d
--- /dev/null
+++ b/web/push_state/basic.dart
@@ -0,0 +1,5 @@
+import 'package:angel_route/browser.dart';
+import '../shared/basic.dart';
+
+main() => basic(new BrowserRouter());
+
diff --git a/web/push_state/basic.html b/web/push_state/basic.html
new file mode 100644
index 00000000..c2b6faca
--- /dev/null
+++ b/web/push_state/basic.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Push State Router
+
+
+
+
+No Active Route
+Handler Sequence:
+
+
+
+
+
\ No newline at end of file
diff --git a/web/shared/basic.dart b/web/shared/basic.dart
new file mode 100644
index 00000000..b35c68eb
--- /dev/null
+++ b/web/shared/basic.dart
@@ -0,0 +1,33 @@
+import 'dart:html';
+import 'package:angel_route/browser.dart';
+
+basic(BrowserRouter router) {
+ final $h1 = window.document.querySelector('h1');
+ final $ul = window.document.getElementById('handlers');
+
+ router.onRoute.listen((route) {
+ if (route == null) {
+ $h1.text = 'No Active Route';
+ $ul.children
+ ..clear()
+ ..add(new LIElement()..text = '(empty)');
+ } else {
+ $h1.text = 'Active Route: ${route.path}';
+ $ul.children
+ ..clear()
+ ..addAll(route.handlerSequence
+ .map((handler) => new LIElement()..text = handler.toString()));
+ }
+ });
+
+ router.get('a', 'a handler');
+
+ router.group('b', (router) {
+ router.get('a', 'b/a handler');
+ router.get('b', 'b/b handler', middleware: ['b/b middleware']);
+ }, middleware: ['b middleware']);
+
+ router.get('c', 'c handler');
+
+ router.dumpTree();
+}
\ No newline at end of file