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