diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee26d6e..c629a1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Add `HostnameRouter`, which allows for routing based on hostname. https://github.com/angel-dart/angel/issues/110 * Default to using `ThrowingReflector`, instead of `EmptyReflector`. This will give a more descriptive error when trying to use controllers, etc. without reflection enabled. +* `mountController` returns the mounted controller. # 2.0.4+1 * Run `Controller.configureRoutes` before mounting `@Expose` routes. diff --git a/lib/src/core/controller.dart b/lib/src/core/controller.dart index 2885614b..2c82cde2 100644 --- a/lib/src/core/controller.dart +++ b/lib/src/core/controller.dart @@ -23,6 +23,11 @@ class Controller { /// A mapping of route paths to routes, produced from the [Expose] annotations on this class. Map routeMappings = {}; + SymlinkRoute _mountPoint; + + /// The route at which this controller is mounted on the server. + SymlinkRoute get mountPoint => _mountPoint; + Controller({this.injectSingleton = true}); /// Applies routes, DI, and other configuration to an [app]. @@ -42,7 +47,7 @@ class Controller { } /// Applies the routes from this [Controller] to some [router]. - Future applyRoutes(Router router, Reflector reflector) async { + Future applyRoutes(Router router, Reflector reflector) async { // Load global expose decl var classMirror = reflector.reflectClass(this.runtimeType); Expose exposeDecl = findExpose(reflector); @@ -52,7 +57,7 @@ class Controller { } var routable = Routable(); - router.mount(exposeDecl.path, routable); + _mountPoint = router.mount(exposeDecl.path, routable); var typeMirror = reflector.reflectType(this.runtimeType); // Pre-reflect methods @@ -77,6 +82,7 @@ class Controller { return (ReflectedDeclaration decl) { var methodName = decl.name; + // Ignore built-in methods. if (methodName != 'toString' && methodName != 'noSuchMethod' && methodName != 'call' && @@ -129,14 +135,18 @@ class Controller { var path = exposeDecl.path; var httpMethod = exposeDecl.method ?? 'GET'; if (path == null) { + // Try to build a route path by finding all potential + // path segments, and then joining them. var parts = []; - var methodMatch = _methods.firstMatch(method.name); + // If the name starts with get/post/patch, etc., then that + // should be the path. + var methodMatch = _methods.firstMatch(method.name); if (methodMatch != null) { var rest = method.name.replaceAll(_methods, ''); var restPath = ReCase(rest.isEmpty ? 'index' : rest) .snakeCase - .replaceAll(_multiScore, '_'); + .replaceAll(_rgxMultipleUnderscores, '_'); httpMethod = methodMatch[1].toUpperCase(); if (['index', 'by_id'].contains(restPath)) { @@ -144,16 +154,23 @@ class Controller { } else { parts.add(restPath); } - } else { + } + // If the name does NOT start with get/post/patch, etc. then + // snake_case-ify the name, and add it to the list of segments. + // If the name is index, though, add "/". + else { if (method.name == 'index') { parts.add('/'); } else { - parts.add( - ReCase(method.name).snakeCase.replaceAll(_multiScore, '_')); + parts.add(ReCase(method.name) + .snakeCase + .replaceAll(_rgxMultipleUnderscores, '_')); } } - // Try to infer String, int, or double. + // Try to infer String, int, or double. We called + // preInject() earlier, so we can figure out the types + // of required parameters, and add those to the path. for (var p in injection.required) { if (p is List && p.length == 2 && p[0] is String && p[1] is Type) { var name = p[0] as String; @@ -169,6 +186,7 @@ class Controller { } path = parts.join('/'); + if (!path.startsWith('/')) path = '/$path'; } routeMappings[name] = routable.addRoute( @@ -190,9 +208,12 @@ class Controller { FutureOr configureRoutes(Routable routable) {} static final RegExp _methods = RegExp(r'^(get|post|patch|delete)'); - static final RegExp _multiScore = RegExp(r'__+'); + static final RegExp _rgxMultipleUnderscores = RegExp(r'__+'); /// Finds the [Expose] declaration for this class. + /// + /// If [concreteOnly] is `false`, then if there is no actual + /// [Expose], one will be automatically created. Expose findExpose(Reflector reflector, {bool concreteOnly = false}) { var existing = reflector .reflectClass(runtimeType) @@ -206,6 +227,6 @@ class Controller { .snakeCase .replaceAll('_controller', '') .replaceAll('_ctrl', '') - .replaceAll(_multiScore, '_'))); + .replaceAll(_rgxMultipleUnderscores, '_'))); } } diff --git a/lib/src/core/server.dart b/lib/src/core/server.dart index e2f71760..6ddd2ed5 100644 --- a/lib/src/core/server.dart +++ b/lib/src/core/server.dart @@ -334,13 +334,15 @@ class Angel extends Routable { } /// Shorthand for using the [container] to instantiate, and then mount a [Controller]. + /// Returns the created controller. /// /// Just like [Container].make, in contexts without properly-reified generics (dev releases of Dart 2), /// provide a [type] argument as well. /// - /// If you are on `Dart >=2.0.0`, simply call `mountController()`.. - Future mountController([Type type]) { - return configure(container.make(type).configureServer); + /// If you are on `Dart >=2.0.0`, simply call `mountController()`. + Future mountController([Type type]) { + var controller = container.make(type); + return configure(controller.configureServer).then((_) => controller); } /// Shorthand for calling `all('*', handler)`. diff --git a/test/controller_test.dart b/test/controller_test.dart index d8cdb05c..0e0a5f3f 100644 --- a/test/controller_test.dart +++ b/test/controller_test.dart @@ -30,7 +30,28 @@ class TodoController extends Controller { } } -class NoExposeController extends Controller {} +class NoExposeController extends Controller { + String getIndex() => 'Hey!'; + + int timesTwo(int n) => n * 2; + + String repeatName(String name, int times) { + var b = StringBuffer(); + for (int i = 0; i < times; i++) { + b.writeln(name); + } + return b.toString(); + } + + @Expose('/yellow', method: 'POST') + String someColor() => 'yellow'; + + @Expose.patch + int three() => 333; + + @noExpose + String hideThis() => 'Should not be exposed'; +} @Expose('/named', as: 'foo') class NamedController extends Controller { @@ -51,6 +72,7 @@ bool bar(RequestContext req, ResponseContext res) { main() { Angel app; TodoController ctrl; + NoExposeController noExposeCtrl; HttpServer server; http.Client client = http.Client(); String url; @@ -70,6 +92,8 @@ main() { // Using mountController(); await app.mountController(); + noExposeCtrl = await app.mountController(); + // Place controller in group... app.group('/ctrl_group', (router) { app.container @@ -94,16 +118,6 @@ main() { expect(ctrl.app, app); }); - test('require expose', () async { - try { - var app = Angel(reflector: MirrorsReflector()); - await app.configure(NoExposeController().configureServer); - throw 'Should require @Expose'; - } on Exception { - // :) - } - }); - test('create dynamic handler', () async { var app = Angel(reflector: MirrorsReflector()); app.get( @@ -154,4 +168,40 @@ main() { print('Response: ${response.body}'); expect(response.body, equals("Hello, \"world!\"")); }); + + group('optional expose', () { + test('removes suffixes from controller names', () { + expect(noExposeCtrl.mountPoint.path, 'no_expose'); + }); + + test('mounts correct routes', () { + print(noExposeCtrl.routeMappings.keys); + expect(noExposeCtrl.routeMappings.keys.toList(), + ['getIndex', 'timesTwo', 'repeatName', 'someColor', 'three']); + }); + + test('mounts correct methods', () { + void expectMethod(String name, String method) { + expect(noExposeCtrl.routeMappings[name].method, method); + } + + expectMethod('getIndex', 'GET'); + expectMethod('timesTwo', 'GET'); + expectMethod('repeatName', 'GET'); + expectMethod('someColor', 'POST'); + expectMethod('three', 'PATCH'); + }); + + test('mounts correct paths', () { + void expectPath(String name, String path) { + expect(noExposeCtrl.routeMappings[name].path, path); + } + + expectPath('getIndex', '/'); + expectPath('timesTwo', '/times_two/int:n'); + expectPath('repeatName', '/repeat_name/:name/int:times'); + expectPath('someColor', '/yellow'); + expectPath('three', '/three'); + }); + }); }