diff --git a/lib/defs.dart b/lib/defs.dart new file mode 100644 index 00000000..53d6074d --- /dev/null +++ b/lib/defs.dart @@ -0,0 +1,6 @@ +library angel_framework.defs; + +/// Represents data that can be serialized into a MemoryService; +class MemoryModel { + int id; +} \ No newline at end of file diff --git a/lib/src/http/controller.dart b/lib/src/http/controller.dart new file mode 100644 index 00000000..d19a98c9 --- /dev/null +++ b/lib/src/http/controller.dart @@ -0,0 +1,99 @@ +part of angel_framework.http; + +class Controller { + List middleware = []; + List routes = []; + Map _mappings = {}; + Expose exposeDecl; + + Future call(Angel app) async { + Routable routable = new Routable() + ..routes.addAll(routes); + app.use(exposeDecl.path, routable); + + TypeMirror typeMirror = reflectType(this.runtimeType); + String name = exposeDecl.as; + + if (name == null || name.isEmpty) + name = MirrorSystem.getName(typeMirror.simpleName); + + app.controllers[name] = this; + } + + Controller() { + // Load global expose decl + ClassMirror classMirror = reflectClass(this.runtimeType); + + for (InstanceMirror metadatum in classMirror.metadata) { + if (metadatum.reflectee is Expose) { + exposeDecl = metadatum.reflectee; + break; + } + } + + if (exposeDecl == null) + throw new Exception( + "All controllers must carry an @Expose() declaration."); + else routes.add( + new Route( + "*", "*", []..addAll(exposeDecl.middleware)..addAll(middleware))); + + InstanceMirror instanceMirror = reflect(this); + classMirror.instanceMembers.forEach((Symbol key, + MethodMirror methodMirror) { + if (methodMirror.isRegularMethod && key != #toString && + key != #noSuchMethod && key != #call && key != #equals && + key != #==) { + InstanceMirror exposeMirror = methodMirror.metadata.firstWhere(( + mirror) => mirror.reflectee is Expose, orElse: () => null); + + if (exposeMirror != null) { + RequestHandler handler = (RequestContext req, + ResponseContext res) async { + List args = []; + + try { + // Load parameters, and execute + for (int i = 0; i < methodMirror.parameters.length; i++) { + ParameterMirror parameter = methodMirror.parameters[i]; + if (parameter.type.reflectedType == RequestContext) + args.add(req); + else if (parameter.type.reflectedType == ResponseContext) + args.add(res); + else { + String name = MirrorSystem.getName(parameter.simpleName); + var arg = req.params[name]; + + if (arg == null && + !exposeMirror.reflectee.allowNull.contain(name)) { + throw new AngelHttpException.BadRequest(); + } + + args.add(arg); + } + } + + return await instanceMirror + .invoke(key, args) + .reflectee; + } catch (e) { + throw new AngelHttpException(e); + } + }; + Route route = new Route( + exposeMirror.reflectee.method, + exposeMirror.reflectee.path, + [handler]..addAll(exposeMirror.reflectee.middleware)); + routes.add(route); + + String name = exposeMirror.reflectee.as; + + if (name == null || name.isEmpty) + name = MirrorSystem.getName(key); + + _mappings[name] = route; + } + } + }); + } +} \ No newline at end of file diff --git a/lib/src/http/http.dart b/lib/src/http/http.dart index 44c163e1..da16ecd9 100644 --- a/lib/src/http/http.dart +++ b/lib/src/http/http.dart @@ -10,7 +10,9 @@ import 'package:body_parser/body_parser.dart'; import 'package:json_god/json_god.dart' as god; import 'package:merge_map/merge_map.dart'; import 'package:mime/mime.dart'; +import '../../defs.dart'; +part 'controller.dart'; part 'extensible.dart'; part 'errors.dart'; part 'metadata/metadata.dart'; @@ -22,3 +24,4 @@ part 'server.dart'; part 'service.dart'; part 'service_hooked.dart'; part 'services/memory.dart'; + diff --git a/lib/src/http/metadata/metadata.dart b/lib/src/http/metadata/metadata.dart index b65fd84a..af164495 100644 --- a/lib/src/http/metadata/metadata.dart +++ b/lib/src/http/metadata/metadata.dart @@ -10,4 +10,18 @@ class Middleware { /// Annotation to set a service up to release hooks on every action. class Hooked { const Hooked(); +} + +class Expose { + final String method; + final Pattern path; + final List middleware; + final String as; + final List allowNull; + + const Expose(Pattern this.path, + {String this.method: "GET", + List this.middleware: const [], + String this.as: null, + List this.allowNull: const[]}); } \ No newline at end of file diff --git a/lib/src/http/response_context.dart b/lib/src/http/response_context.dart index 0b46cfd1..42c1a0e4 100644 --- a/lib/src/http/response_context.dart +++ b/lib/src/http/response_context.dart @@ -103,6 +103,27 @@ class ResponseContext extends Extensible { throw new ArgumentError.notNull('Route to redirect to ($name)'); } + /// Redirects to the given [Controller] action. + redirectToAction(String action, [Map params, int code]) { + // UserController@show + List split = action.split("@"); + + if (split.length < 2) + throw new Exception("Controller redirects must take the form of 'Controller@action'. You gave: $action"); + + Controller controller = app.controller(split[0]); + + if (controller == null) + throw new Exception("Could not find a controller named '${split[0]}'"); + + Route matched = controller._mappings[split[1]]; + + if (matched == null) + throw new Exception("Controller '${split[0]}' does not contain any action named '${split[1]}'"); + + return redirect(matched.makeUri(params), code: code); + } + /// 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 721f04ce..e3dd0439 100644 --- a/lib/src/http/routable.dart +++ b/lib/src/http/routable.dart @@ -37,6 +37,9 @@ class Routable extends Extensible { /// A set of [Service] objects that have been mapped into routes. Map services = {}; + /// A set of [Controller] objects that have been loaded into the application. + Map controllers = {}; + /// Assigns a middleware to a name for convenience. registerMiddleware(String name, RequestMiddleware middleware) { this.requestMiddleware[name] = middleware; @@ -45,6 +48,9 @@ class Routable extends Extensible { /// Retrieves the service assigned to the given path. Service service(Pattern path) => services[path]; + /// Retrieves the controller with the given name. + Controller controller(String name) => controllers[name]; + /// Incorporates another [Routable]'s routes into this one's. /// /// If `hooked` is set to `true` and a [Service] is provided, diff --git a/lib/src/http/services/memory.dart b/lib/src/http/services/memory.dart index 06a777f4..7619f8c5 100644 --- a/lib/src/http/services/memory.dart +++ b/lib/src/http/services/memory.dart @@ -1,10 +1,5 @@ part of angel_framework.http; -/// Represents data that can be serialized into a MemoryService; -class MemoryModel { - int id; -} - /// An in-memory [Service]. class MemoryService extends Service { Map items = {}; diff --git a/pubspec.yaml b/pubspec.yaml index d63a18b4..852d5697 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: angel_framework -version: 1.0.0-dev+5 +version: 1.0.0-dev+6 description: Core libraries for the Angel framework. author: Tobe O homepage: https://github.com/angel-dart/angel_framework diff --git a/test/common.dart b/test/common.dart new file mode 100644 index 00000000..54a28992 --- /dev/null +++ b/test/common.dart @@ -0,0 +1,8 @@ +library angel_framework.test.common; + +class Todo { + String text; + String over; + + Todo({String this.text, String this.over}); +} diff --git a/test/controller.dart b/test/controller.dart new file mode 100644 index 00000000..41faed23 --- /dev/null +++ b/test/controller.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; +import 'common.dart'; + +@Expose("/todos", middleware: const ["foo"]) +class TodoController extends Controller { + List todos = [new Todo(text: "Hello", over: "world")]; + + @Expose("/:id", middleware: const["bar"]) + Future fetchTodo(int id, RequestContext req, + ResponseContext res) async { + expect(req, isNotNull); + expect(res, isNotNull); + return todos[id]; + } + + @Expose("/namedRoute/:foo", as: "foo") + Future someRandomRoute(RequestContext req, ResponseContext res) async { + return "${req.params['foo']}!"; + } +} + +main() { + group("controller", () { + Angel app = new Angel(); + HttpServer server; + InternetAddress host = InternetAddress.LOOPBACK_IP_V4; + int port = 3000; + http.Client client; + String url = "http://${host.address}:$port"; + + setUp(() async { + app.registerMiddleware("foo", (req, res) async => res.write("Hello, ")); + app.registerMiddleware("bar", (req, res) async => res.write("world!")); + app.get("/redirect", (req, ResponseContext res) async => + res.redirectToAction("TodoController@foo", {"foo": "world"})); + await app.configure(new TodoController()); + + print(app.controllers); + print("\nDUMPING ROUTES:"); + app.routes.forEach((Route route) { + print("\t${route.method} ${route.path} -> ${route.handlers}"); + }); + print("\n"); + + server = await app.startServer(host, port); + client = new http.Client(); + }); + + tearDown(() async { + await server.close(force: true); + client.close(); + client = null; + }); + + test("middleware", () async { + var response = await client.get("$url/todos/0"); + print(response.body); + + expect(response.body.indexOf("Hello, "), equals(0)); + + Map todo = JSON.decode(response.body.substring(7)); + expect(todo.keys.length, equals(2)); + expect(todo['text'], equals("Hello")); + expect(todo['over'], equals("world")); + }); + + test("named actions", () async { + var response = await client.get("$url/redirect"); + print(response.body); + + expect(response.body, equals("Hello, \"world!\"")); + }); + }); +} diff --git a/test/hooked.dart b/test/hooked.dart index f61512c5..c4d5616f 100644 --- a/test/hooked.dart +++ b/test/hooked.dart @@ -2,13 +2,7 @@ import 'package:angel_framework/angel_framework.dart'; import 'package:http/http.dart' as http; import 'package:json_god/json_god.dart' as god; import 'package:test/test.dart'; - -class Todo { - String text; - String over; - - Todo({String this.text, String this.over}); -} +import 'common.dart'; main() { group('Hooked', () {