library angel_route.src.router; import 'extensible.dart'; import 'routing_exception.dart'; part 'route.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 _slashDollar = new RegExp(r'/+\$'); final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); /// An abstraction over complex [Route] trees. Use this instead of the raw API. :) class Router extends Extensible { Route _root; /// 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. Route get root => _root; /// Provide a `root` to make this Router revolve around a pre-defined route. /// Not recommended. Router({this.debug: false, Route root}) { _root = (_root = root ?? new _RootRoute()) ..debug = debug; } void _printDebug(msg) { if (debug) print(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. Route addRoute(String method, Pattern path, Object handler, {List middleware}) { List handlers = []; handlers ..addAll(middleware ?? []) ..add(handler); if (path is RegExp) { return root.child(path, debug: debug, handlers: handlers, method: method); } else if (path .toString() .replaceAll(_straySlashes, '') .isEmpty) { return root.child(path.toString(), debug: debug, 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('/', debug: debug, handlers: handlers, method: method) ..debug = debug; } else { result = resolveOnRoot(segments[0], filter: (route) => route.method == method || route.method == '*'); 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], filter: (route) => route.method == method || route.method == '*'); if (existing != null) { result = existing; } } while (existing != null); } } } for (int i = 0; i < segments.length; i++) { final segment = segments[i]; if (i == segments.length - 1) { if (result == null) { result = root.child(segment, debug: debug, handlers: handlers, method: method); } else { result = result.child(segment, debug: debug, handlers: handlers, method: method); } } else { if (result == null) { result = root.child(segment, debug: debug, method: "*"); } else { result = result.child(segment, debug: debug, method: "*"); } } } return result..debug = debug; } } /// 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, {Pattern replace: null}) { for (var i = 0; i < tabs; i++) buf.write(tab); if (route == root) buf.writeln('(root)'); else { buf.write('- ${route.method} '); var p = replace != null ? route.path.replaceAll(replace, '') : route.path; p = p.replaceAll(_straySlashes, ''); if (p.isEmpty) buf.write("'/'"); else buf.write("'${p.replaceAll(_straySlashes, '')}'"); if (route.handlers.isNotEmpty) buf.writeln(' => ${route.handlers.length} handler(s)'); else buf.writeln(); } tabs++; route.children .forEach((r) => dumpRoute(r, replace: new RegExp("^${route.path}"))); tabs--; } if (header != null && header.isNotEmpty) buf.writeln(header); dumpRoute(root); (callback ?? print)(buf.toString()); } /// 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(root: 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 resolveOnRoot(String path, {bool filter(Route route)}) => root.resolve(path, filter: filter); /// Finds the first [Route] that matches the given path, /// with the given method. Route resolve(String path, {String method: 'GET'}) { final String _path = path.replaceAll(_straySlashes, ''); final segments = _path.split('/').where((str) => str.isNotEmpty); _printDebug('Segments: $segments'); return _resolve(root, _path, method, segments.first, segments.skip(1)); } _validHead(RegExp rgx) { return !rgx.hasMatch(''); } _resolve(Route ref, String fullPath, String method, String head, Iterable tail) { _printDebug( '$method on $ref: path: $fullPath, head: $head, tail: ${tail.join( '/')}'); // Does the index route match? if (ref.matcher.hasMatch(fullPath)) { final index = ref.indexRoute; for (Route child in ref.allIndices) { _printDebug('Possible index: $child'); if (child == child.indexRoute && ['*', method].contains(child.method)) { _printDebug('Possible index was exact match: $child'); return child; } final resolved = _resolve(child, fullPath, method, head, tail); if (resolved != null) { _printDebug('Resolved from possible index: $resolved'); return resolved; } else _printDebug('Possible index returned null: $child'); } if (['*', method].contains(index.method)) { return index; } } else { // Try to match children by full path for (Route child in ref.children) { if (child.matcher.hasMatch(fullPath)) { final resolved = _resolve(child, fullPath, method, head, tail); if (resolved != null) { return resolved; } } } // Now, let's check if any route's head matches the // given head. If so, we try to resolve with that // route, using a head corresponding to the one we // matched. for (Route child in ref.children) { if (child._head != null && child._head.hasMatch(fullPath) && _validHead(child._head)) { final newHead = child._head .firstMatch(fullPath) .group(0) .replaceAll(_straySlashes, ''); final newTail = fullPath .replaceAll(child._head, '') .replaceAll(_straySlashes, '') .split('/') .where((str) => str.isNotEmpty); final resolved = _resolve(child, fullPath, method, newHead, newTail); if (resolved != null) { _printDebug( 'Head match: $resolved from head: ${child._head.pattern}'); return resolved; } } else if (child._head != null) { _printDebug( 'Head ${child._head .pattern} on $child failed to match $fullPath'); } } } if (tail.isEmpty) return null; else { return _resolve( ref, fullPath, method, head + '/' + tail.first, tail.skip(1)); } } /// Returns a new Router in which the route tree has been /// flattened into a linear list. Router flatten() { final router = new Router(); _flatten(Route route) { // if (route.children.isNotEmpty && route.method == '*') return; final r = new Route._base(); r .._handlers.addAll(route.handlerSequence) .._head = route._head .._matcher = route.matcher .._method = route.method .._parent = router.root .._path = route.path; router.root._children.add(r); route.children.forEach(_flatten); } root._children.forEach(_flatten); return router..debug = debug; } /// 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 mount(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]; } // final route = root.addChild(router.root, join: false); final route = root.child(path, debug: debug).addChild(router.root); route.debug = debug; if (path is! RegExp) { final _path = path.toString().replaceAll(_straySlashes, ''); _migrateRoute(Route r) { r._path = '$_path/${r.path}'.replaceAll(_straySlashes, ''); var stripped = r.matcher.pattern .replaceAll(_rgxStart, '') .replaceAll(_rgxEnd, '') .replaceAll(_rgxStraySlashes, '') .replaceAll(_straySlashes, ''); stripped = '$_path/$stripped'.replaceAll(_straySlashes, ''); r._matcher = new RegExp('^$stripped\$'); if (r._head != null) { final head = r._head.pattern .replaceAll(_rgxStart, '') .replaceAll(_rgxEnd, '') .replaceAll(_rgxStraySlashes, '') .replaceAll('\\/', '/') .replaceAll(_straySlashes, ''); r._head = new RegExp(_matcherify('$_path/$head').replaceAll(_rgxEnd, '')); _printDebug('Head of migrated route: ${r._head.pattern}'); } r.children.forEach(_migrateRoute); } route.children.forEach(_migrateRoute); } } /// Removes empty routes that could complicate route resolution. void normalize() { _printDebug('Normalizing route tree...'); _normalize(Route route) { route.children.forEach(_normalize); if (route.path .replaceAll(_straySlashes, '') .isEmpty && route.children.isNotEmpty) { _printDebug('Erasing this route: $route'); route.parent._handlers.addAll(route.handlers); for (Route child in route.children) { route.parent._children.add(child.._parent = route.parent); } route.parent._children.remove(route); } } root.children.forEach(_normalize); } /// 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); } } class _RootRoute extends Route { _RootRoute() : super("/", method: '*', name: ""); @override String toString() => "ROOT"; }