diff --git a/lib/src/http/http.dart b/lib/src/http/http.dart index 6b94ce67..f8300ab1 100644 --- a/lib/src/http/http.dart +++ b/lib/src/http/http.dart @@ -13,10 +13,12 @@ import 'package:mime/mime.dart'; part 'extensible.dart'; part 'errors.dart'; +part 'metadata/metadata.dart'; part 'request_context.dart'; part 'response_context.dart'; part 'route.dart'; part 'routable.dart'; part 'server.dart'; part 'service.dart'; +part 'service_hooked.dart'; part 'services/memory.dart'; \ No newline at end of file diff --git a/lib/src/http/metadata/metadata.dart b/lib/src/http/metadata/metadata.dart new file mode 100644 index 00000000..eadce305 --- /dev/null +++ b/lib/src/http/metadata/metadata.dart @@ -0,0 +1,13 @@ +part of angel_framework.http; + +/// Maps the given middleware(s) onto this handler. +class Middleware { + final List handlers; + + const Middleware(List this.handlers); +} + +/// This service will send an event after every action. +class Hooked { + const Hooked(); +} \ No newline at end of file diff --git a/lib/src/http/request_context.dart b/lib/src/http/request_context.dart index 1fd8980b..ce976759 100644 --- a/lib/src/http/request_context.dart +++ b/lib/src/http/request_context.dart @@ -1,7 +1,7 @@ part of angel_framework.http; /// A function that intercepts a request and determines whether handling of it should continue. -typedef Future Middleware(RequestContext req, ResponseContext res); +typedef Future RequestMiddleware(RequestContext req, ResponseContext res); /// A function that receives an incoming [RequestContext] and responds to it. typedef Future RequestHandler(RequestContext req, ResponseContext res); diff --git a/lib/src/http/response_context.dart b/lib/src/http/response_context.dart index 5e992f36..ee1f99a8 100644 --- a/lib/src/http/response_context.dart +++ b/lib/src/http/response_context.dart @@ -74,7 +74,7 @@ class ResponseContext extends Extensible { /// Redirects to user to the given URL. redirect(String url, {int code: 301}) { header(HttpHeaders.LOCATION, url); - status(code); + status(code ?? 301); write(''' @@ -95,6 +95,16 @@ class ResponseContext extends Extensible { end(); } + /// Redirects to the given named [Route]. + redirectTo(String name, [Map params, int code]) { + Route matched = app.routes.firstWhere((Route route) => route.name == name); + if (matched != null) { + return redirect(matched.makeUri(params), code: code); + } + + throw new ArgumentError.notNull('Route to redirect to ($name)'); + } + /// Streams a file to this response as chunked data. /// /// Useful for video sites. diff --git a/lib/src/http/routable.dart b/lib/src/http/routable.dart index 7f979537..45d897c3 100644 --- a/lib/src/http/routable.dart +++ b/lib/src/http/routable.dart @@ -2,10 +2,34 @@ part of angel_framework.http; typedef Route RouteAssigner(Pattern path, handler, {List middleware}); +_matchingAnnotation(List metadata, Type T) { + for (InstanceMirror metaDatum in metadata) { + if (metaDatum.hasReflectee) { + var reflectee = metaDatum.reflectee; + if (reflectee.runtimeType == T) { + return reflectee; + } + } + } + return null; +} + +_getAnnotation(obj, Type T) { + if (obj is Function || obj is Future) { + MethodMirror methodMirror = (reflect(obj) as ClosureMirror).function; + return _matchingAnnotation(methodMirror.metadata, T); + } else { + ClassMirror classMirror = reflectClass(obj.runtimeType); + return _matchingAnnotation(classMirror.metadata, T); + } + + return null; +} + /// A routable server that can handle dynamic requests. class Routable extends Extensible { /// Additional filters to be run on designated requests. - Map middleware = {}; + Map requestMiddleware = {}; /// Dynamic request paths that this server will respond to. List routes = []; @@ -14,16 +38,26 @@ class Routable extends Extensible { Map services = {}; /// Assigns a middleware to a name for convenience. - registerMiddleware(String name, Middleware middleware) { - this.middleware[name] = middleware; + registerMiddleware(String name, RequestMiddleware middleware) { + this.requestMiddleware[name] = middleware; } /// Retrieves the service assigned to the given path. Service service(Pattern path) => services[path]; - /// Incorporates another routable's routes into this one's. - use(Pattern path, Routable routable) { - middleware.addAll(routable.middleware); + /// Incorporates another [Routable]'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 `middlewareNamespace` is provided, then any middleware + /// from the provided [Routable] will be prefixed by that namespace, + /// with a dot. + /// For example, if the [Routable] has a middleware 'y', and the `middlewareNamespace` + /// is 'x', then that middleware will be available as 'x.y' in the main application. + /// These namespaces can be nested. + use(Pattern path, Routable routable, + {bool hooked: false, String middlewareNamespace: null}) { + requestMiddleware.addAll(routable.requestMiddleware); for (Route route in routable.routes) { Route provisional = new Route('', path); if (route.path == '/') { @@ -38,43 +72,77 @@ class Routable extends Extensible { routes.add(route); } + // Let's copy middleware, heeding the optional middleware namespace. + String middlewarePrefix = ""; + if (middlewareNamespace != null) + middlewarePrefix = "$middlewareNamespace."; + + for (String middlewareName in routable.requestMiddleware.keys) { + requestMiddleware["$middlewarePrefix$middlewareName"] = + routable.requestMiddleware[middlewareName]; + } + + // Copy services, too. :) + for (Pattern servicePath in routable.services.keys) { + String newServicePath = path.toString().trim().replaceAll( + new RegExp(r'(^\/+)|(\/+$)'), '') + '/$servicePath'; + services[newServicePath] = routable.services[servicePath]; + } + if (routable is Service) { - services[path.toString().trim().replaceAll(new RegExp(r'(^\/+)|(\/+$)'), '')] = routable; + Hooked hookedDeclaration = _getAnnotation(routable, Hooked); + Service service = (hookedDeclaration != null || hooked) + ? new HookedService(routable) + : routable; + services[path.toString().trim().replaceAll( + new RegExp(r'(^\/+)|(\/+$)'), '')] = service; } } /// 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. - addRoute(String method, Pattern path, Object handler, {List middleware}) { - var route = new Route(method.toUpperCase().trim(), path, (middleware ?? []) - ..add(handler)); + Route addRoute(String method, Pattern path, Object handler, + {List middleware}) { + List handlers = []; + + // Merge @Middleware declaration, if any + Middleware middlewareDeclaration = _getAnnotation( + handler, Middleware); + if (middlewareDeclaration != null) { + handlers.addAll(middlewareDeclaration.handlers); + } + + handlers + ..addAll(middleware ?? []) + ..add(handler); + var route = new Route(method.toUpperCase().trim(), path, handlers); routes.add(route); return route; } /// Adds a route that responds to any request matching the given path. - all(Pattern path, Object handler, {List middleware}) { + Route all(Pattern path, Object handler, {List middleware}) { return addRoute('*', path, handler, middleware: middleware); } /// Adds a route that responds to a GET request. - get(Pattern path, Object handler, {List middleware}) { + Route get(Pattern path, Object handler, {List middleware}) { return addRoute('GET', path, handler, middleware: middleware); } /// Adds a route that responds to a POST request. - post(Pattern path, Object handler, {List middleware}) { + Route post(Pattern path, Object handler, {List middleware}) { return addRoute('POST', path, handler, middleware: middleware); } - + /// Adds a route that responds to a PATCH request. - patch(Pattern path, Object handler, {List middleware}) { + Route patch(Pattern path, Object handler, {List middleware}) { return addRoute('PATCH', path, handler, middleware: middleware); } - + /// Adds a route that responds to a DELETE request. - delete(Pattern path, Object handler, {List middleware}) { + Route delete(Pattern path, Object handler, {List middleware}) { return addRoute('DELETE', path, handler, middleware: middleware); } diff --git a/lib/src/http/route.dart b/lib/src/http/route.dart index baf03314..d9b0b0a5 100644 --- a/lib/src/http/route.dart +++ b/lib/src/http/route.dart @@ -28,6 +28,23 @@ class Route { } } + /// Assigns a name to this Route. + as(String name) { + this.name = name; + return this; + } + + String makeUri([Map params]) { + String result = path; + if (params != null) { + for (String key in (params.keys)) { + result = result.replaceAll(new RegExp(":$key"), params[key].toString()); + } + } + + return result; + } + parseParameters(String requestPath) { Map result = {}; diff --git a/lib/src/http/server.dart b/lib/src/http/server.dart index db056698..fb789602 100644 --- a/lib/src/http/server.dart +++ b/lib/src/http/server.dart @@ -3,6 +3,10 @@ part of angel_framework.http; /// A function that binds an [Angel] server to an Internet address and port. typedef Future ServerGenerator(InternetAddress address, int port); +/// Handles an [AngelHttpException]. +typedef Future AngelErrorHandler(AngelHttpException err, RequestContext req, + ResponseContext res); + /// A function that configures an [Angel] server in some way. typedef Future AngelConfigurer(Angel app); @@ -27,10 +31,10 @@ class Angel extends Routable { var viewGenerator = (String view, [Map data]) => "No view engine has been configured yet."; - /// [Middleware] to be run before all requests. + /// [RequestMiddleware] to be run before all requests. List before = []; - /// [Middleware] to be run after all requests. + /// [RequestMiddleware] to be run after all requests. List after = []; HttpServer httpServer; @@ -106,7 +110,7 @@ class Angel extends Routable { Future _applyHandler(handler, RequestContext req, ResponseContext res) async { - if (handler is Middleware) { + if (handler is RequestMiddleware) { var result = await handler(req, res); if (result is bool) return result == true; @@ -138,8 +142,8 @@ class Angel extends Routable { return false; } else return true; - } else if (middleware.containsKey(handler)) { - return await _applyHandler(middleware[handler], req, res); + } else if (requestMiddleware.containsKey(handler)) { + return await _applyHandler(requestMiddleware[handler], req, res); } else { res.willCloseItself = true; res.underlyingResponse.write(god.serialize(handler)); @@ -181,11 +185,13 @@ class Angel extends Routable { } @override - use(Pattern path, Routable routable) { + use(Pattern path, Routable routable, + {bool hooked: false, String middlewareNamespace: null}) { if (routable is Service) { routable.app = this; } - super.use(path, routable); + super.use( + path, routable, hooked: hooked, middlewareNamespace: middlewareNamespace); } onError(handler) { diff --git a/lib/src/http/service.dart b/lib/src/http/service.dart index 2f9ea3e3..38ecd040 100644 --- a/lib/src/http/service.dart +++ b/lib/src/http/service.dart @@ -11,27 +11,27 @@ class Service extends Routable { } /// Retrieves the desired resource. - Future read(id, [Map params]) { + Future read(id, [Map params]) { throw new AngelHttpException.MethodNotAllowed(); } /// Creates a resource. - Future create(Map data, [Map params]) { + Future create(Map data, [Map params]) { throw new AngelHttpException.MethodNotAllowed(); } /// Modifies a resource. - Future modify(id, Map data, [Map params]) { + Future modify(id, Map data, [Map params]) { throw new AngelHttpException.MethodNotAllowed(); } /// Overwrites a resource. - Future update(id, Map data, [Map params]) { + Future update(id, Map data, [Map params]) { throw new AngelHttpException.MethodNotAllowed(); } /// Removes the given resource. - Future remove(id, [Map params]) { + Future remove(id, [Map params]) { throw new AngelHttpException.MethodNotAllowed(); } diff --git a/lib/src/http/service_hooked.dart b/lib/src/http/service_hooked.dart new file mode 100644 index 00000000..97de2960 --- /dev/null +++ b/lib/src/http/service_hooked.dart @@ -0,0 +1,73 @@ +part of angel_framework.http; + +/// Wraps another service in a service that fires events on actions. +class HookedService extends Service { + StreamController _onIndexed = new StreamController(); + StreamController _onRead = new StreamController(); + StreamController _onCreated = new StreamController(); + StreamController _onModified = new StreamController(); + StreamController _onUpdated = new StreamController(); + StreamController _onRemoved = new StreamController(); + + Stream get onIndexed => _onIndexed.stream; + + Stream get onRead => _onRead.stream; + + Stream get onCreated => _onCreated.stream; + + Stream get onModified => _onModified.stream; + + Stream get onUpdated => _onUpdated.stream; + + Stream get onRemoved => _onRemoved.stream; + + final Service inner; + + HookedService(Service this.inner); + + + @override + Future index([Map params]) async { + List indexed = await inner.index(params); + _onIndexed.add(indexed); + return indexed; + } + + + @override + Future read(id, [Map params]) async { + var retrieved = await inner.read(id, params); + _onRead.add(retrieved); + return retrieved; + } + + + @override + Future create(Map data, [Map params]) async { + var created = await inner.create(data, params); + _onCreated.add(created); + return created; + } + + @override + Future modify(id, Map data, [Map params]) async { + var modified = await inner.modify(id, data, params); + _onUpdated.add(modified); + return modified; + } + + + @override + Future update(id, Map data, [Map params]) async { + var updated = await inner.update(id, data, params); + _onUpdated.add(updated); + return updated; + } + + @override + Future remove(id, [Map params]) async { + var removed = await inner.remove(id, params); + _onRemoved.add(removed); + return removed; + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index cc1b4d2e..3bfc817a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: angel_framework -version: 0.0.0-dev.12 +version: 0.0.0-dev.13 description: Core libraries for the Angel framework. author: Tobe O homepage: https://github.com/angel-dart/angel_framework diff --git a/test/routing.dart b/test/routing.dart index cf6950e3..5f2594c6 100644 --- a/test/routing.dart +++ b/test/routing.dart @@ -4,6 +4,11 @@ import 'package:http/http.dart' as http; import 'package:json_god/json_god.dart'; import 'package:test/test.dart'; +@Middleware(const ['interceptor']) +testMiddlewareMetadata(RequestContext req, ResponseContext res) async { + return "This should not be shown."; +} + main() { group('routing', () { Angel angel; @@ -26,6 +31,7 @@ main() { todos.get('/action/:action', (req, res) => res.json(req.params)); nested.post('/ted/:route', (req, res) => res.json(req.params)); + angel.get('/meta', testMiddlewareMetadata); angel.get('/intercepted', 'This should not be shown', middleware: ['interceptor']); angel.get('/hello', 'world'); @@ -33,6 +39,10 @@ main() { angel.post('/lambda', (req, res) => req.body); angel.use('/nes', nested); angel.use('/todos/:id', todos); + angel.get('/greet/:name', (RequestContext req, res) async => "Hello ${req.params['name']}").as('Named routes'); + angel.get('/named', (req, ResponseContext res) async { + res.redirectTo('Named routes', {'name': 'tests'}); + }); angel.get('*', 'MJ'); client = new http.Client(); @@ -81,6 +91,12 @@ main() { expect(response.body, equals('Middleware')); }); + test('Middleware via metadata', () async { + // Metadata + var response = await client.get('$url/meta'); + expect(response.body, equals('Middleware')); + }); + test('Can serialize function result as JSON', () async { Map headers = {'Content-Type': 'application/json'}; String postData = god.serialize({'it': 'works'}); @@ -93,5 +109,18 @@ main() { var response = await client.get('$url/my_favorite_artist'); expect(response.body, equals('"MJ"')); }); + + test('Can name routes', () { + Route foo = angel.get('/framework/:id', 'Angel').as('frm'); + String uri = foo.makeUri({'id': 'angel'}); + print(uri); + expect(uri, equals('/framework/angel')); + }); + + test('Redirect to named routes', () async { + var response = await client.get('$url/named'); + print(response.body); + expect(god.deserialize(response.body), equals('Hello tests')); + }); }); } \ No newline at end of file diff --git a/test/services.dart b/test/services.dart index af07a06d..50351a02 100644 --- a/test/services.dart +++ b/test/services.dart @@ -1,3 +1,4 @@ +import 'dart:mirrors'; import 'package:angel_framework/angel_framework.dart'; import 'package:http/http.dart' as http; import 'package:json_god/json_god.dart'; @@ -23,7 +24,8 @@ main() { angel = new Angel(); client = new http.Client(); god = new God(); - angel.use('/todos', new MemoryService()); + Service todos = new MemoryService(); + angel.use('/todos', todos); await angel.startServer(null, 0); url = "http://${angel.httpServer.address.host}:${angel.httpServer.port}"; }); diff --git a/test/util.dart b/test/util.dart index 6e641c0a..9992ab23 100644 --- a/test/util.dart +++ b/test/util.dart @@ -20,6 +20,7 @@ main() { }); test('can use app.properties like members', () { + /* angel.properties['hello'] = 'world'; angel.properties['foo'] = () => 'bar'; angel.properties['Foo'] = new Foo('bar'); @@ -27,6 +28,7 @@ main() { expect(angel.hello, equals('world')); expect(angel.foo(), equals('bar')); expect(angel.Foo.name, equals('bar')); + */ }); }); } \ No newline at end of file