diff --git a/README.md b/README.md new file mode 100644 index 00000000..4583a5d4 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# angel_framework +Documentation in the works. \ No newline at end of file diff --git a/lib/src/http/errors.dart b/lib/src/http/errors.dart new file mode 100644 index 00000000..512d1daf --- /dev/null +++ b/lib/src/http/errors.dart @@ -0,0 +1,96 @@ +part of angel_framework.http; + +class _AngelHttpExceptionBase implements Exception { + int statusCode; + String message; + List errors; + + _AngelHttpExceptionBase.base() {} + + _AngelHttpExceptionBase(this.statusCode, this.message, + {List this.errors: const []}); + + @override + String toString() { + return "$statusCode: $message"; + } + + Map toMap() { + return { + 'isError': true, + 'statusCode': statusCode, + 'message': message, + 'errors': errors + }; + } +} + +/// Basically the same as +/// [feathers-errors](https://github.com/feathersjs/feathers-errors). +class AngelHttpException extends _AngelHttpExceptionBase { + /// Throws a 500 Internal Server Error. + /// Set includeRealException to true to print include the actual exception along with + /// this error. Useful flag for development vs. production. + AngelHttpException(Exception exception, + {bool includeRealException: false, StackTrace stackTrace}) :super.base() { + statusCode = 500; + message = "500 Internal Server Error"; + if (includeRealException) { + errors.add(exception.toString()); + if (stackTrace != null) { + errors.add(stackTrace.toString()); + } + } + } + + /// Throws a 400 Bad Request error, including an optional arrray of (validation?) + /// errors you specify. + AngelHttpException.BadRequest( + {String message: '400 Bad Request', List errors: const[]}) + : super(400, message, errors: errors); + + /// Throws a 401 Not Authenticated error. + AngelHttpException.NotAuthenticated({String message: '401 Not Authenticated'}) + : super(401, message); + + /// Throws a 402 Payment Required error. + AngelHttpException.PaymentRequired({String message: '402 Payment Required'}) + : super(402, message); + + /// Throws a 403 Forbidden error. + AngelHttpException.Forbidden({String message: '403 Forbidden'}) + : super(403, message); + + /// Throws a 404 Not Found error. + AngelHttpException.NotFound({String message: '404 Not Found'}) + : super(404, message); + + /// Throws a 405 Method Not Allowed error. + AngelHttpException.MethodNotAllowed( + {String message: '405 Method Not Allowed'}) + : super(405, message); + + /// Throws a 406 Not Acceptable error. + AngelHttpException.NotAcceptable({String message: '406 Not Acceptable'}) + : super(406, message); + + /// Throws a 408 Timeout error. + AngelHttpException.MethodTimeout({String message: '408 Timeout'}) + : super(408, message); + + /// Throws a 409 Conflict error. + AngelHttpException.Conflict({String message: '409 Conflict'}) + : super(409, message); + + /// Throws a 422 Not Processable error. + AngelHttpException.NotProcessable({String message: '422 Not Processable'}) + : super(422, message); + + /// Throws a 501 Not Implemented error. + AngelHttpException.NotImplemented({String message: '501 Not Implemented'}) + : super(501, message); + + /// Throws a 503 Unavailable error. + AngelHttpException.Unavailable({String message: '503 Unavailable'}) + : super(503, message); +} \ No newline at end of file diff --git a/lib/src/http/http.dart b/lib/src/http/http.dart index e42cace5..6b94ce67 100644 --- a/lib/src/http/http.dart +++ b/lib/src/http/http.dart @@ -4,13 +4,15 @@ library angel_framework.http; import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'dart:mirrors'; import 'package:body_parser/body_parser.dart'; import 'package:json_god/json_god.dart'; +import 'package:merge_map/merge_map.dart'; import 'package:mime/mime.dart'; -import 'package:route/server.dart'; part 'extensible.dart'; +part 'errors.dart'; part 'request_context.dart'; part 'response_context.dart'; part 'route.dart'; diff --git a/lib/src/http/routable.dart b/lib/src/http/routable.dart index 4c3abe67..23f3fce5 100644 --- a/lib/src/http/routable.dart +++ b/lib/src/http/routable.dart @@ -13,15 +13,6 @@ class Routable extends Extensible { /// A set of [Service] objects that have been mapped into routes. Map services = {}; - _makeRouteAssigner(String method) { - return (Pattern path, Object handler, {List middleware}) { - var route = new Route(method, path, (middleware ?? []) - ..add(handler)); - routes.add(route); - return route; - }; - } - /// Assigns a middleware to a name for convenience. registerMiddleware(String name, Middleware middleware) { this.middleware[name] = middleware; @@ -35,6 +26,10 @@ class Routable extends Extensible { middleware.addAll(routable.middleware); for (Route route in routable.routes) { Route provisional = new Route('', path); + if (route.path == '/') { + route.path = ''; + route.matcher = new RegExp(r'^\/?$'); + } route.matcher = new RegExp(route.matcher.pattern.replaceAll( new RegExp('^\\^'), provisional.matcher.pattern.replaceAll(new RegExp(r'\$$'), ''))); @@ -48,13 +43,42 @@ class Routable extends Extensible { } } - RouteAssigner get, post, patch, delete; + /// 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)); + routes.add(route); + return route; + } + + /// Adds a route that responds to any request matching the given path. + 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}) { + return addRoute('GET', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a POST request. + 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}) { + return addRoute('PATCH', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a DELETE request. + delete(Pattern path, Object handler, {List middleware}) { + return addRoute('DELETE', path, handler, middleware: middleware); + } Routable() { - this.get = _makeRouteAssigner('GET'); - this.post = _makeRouteAssigner('POST'); - this.patch = _makeRouteAssigner('PATCH'); - this.delete = _makeRouteAssigner('DELETE'); } } \ No newline at end of file diff --git a/lib/src/http/server.dart b/lib/src/http/server.dart index ef22018d..a8cc858d 100644 --- a/lib/src/http/server.dart +++ b/lib/src/http/server.dart @@ -4,57 +4,97 @@ part of angel_framework.http; typedef Future ServerGenerator(InternetAddress address, int port); /// A function that configures an [Angel] server in some way. -typedef AngelConfigurer(Angel app); +typedef Future AngelConfigurer(Angel app); /// A powerful real-time/REST/MVC server class. class Angel extends Routable { - ServerGenerator _serverGenerator = (address, port) async => await HttpServer - .bind(address, port); - var viewGenerator = (String view, - [Map data]) => "No view engine has been configured yet."; + ServerGenerator _serverGenerator = + (address, port) async => await HttpServer.bind(address, port); + + /// Default error handler, show HTML error page + var _errorHandler = (AngelHttpException e, req, ResponseContext res) { + res.status(e.statusCode); + res.write("${e.message}"); + res.write("

${e.message}

    "); + for (String error in e.errors) { + res.write("
  • $error
  • "); + } + res.write("
"); + res.end(); + }; + + var viewGenerator = + (String view, [Map data]) => "No view engine has been configured yet."; + + /// [Middleware] to be run before all requests. + List before = []; + + /// [Middleware] to be run after all requests. + List after = []; HttpServer httpServer; God god = new God(); startServer(InternetAddress address, int port) async { - var server = await _serverGenerator( - address ?? InternetAddress.LOOPBACK_IP_V4, port); + var server = + await _serverGenerator(address ?? InternetAddress.LOOPBACK_IP_V4, port); this.httpServer = server; - var router = new Router(server); - this.routes.forEach((Route route) { - router.serve(route.matcher, method: route.method).listen(( - HttpRequest request) async { - RequestContext req = await RequestContext.from( - request, route.parseParameters(request.uri.toString()), this, - route); - ResponseContext res = await ResponseContext.from( - request.response, this); - bool canContinue = true; + server.listen((HttpRequest request) async { + String req_url = + request.uri.toString().replaceAll(new RegExp(r'\/+$'), ''); + RequestContext req = await RequestContext.from(request, {}, this, null); + ResponseContext res = await ResponseContext.from(request.response, this); - for (var handler in route.handlers) { - if (canContinue) { - canContinue = await new Future.sync(() async { - return _applyHandler(handler, req, res); - }).catchError((e) { - stderr.write(e.error); - canContinue = false; - return false; - }); + bool canContinue = true; + + var execHandler = (handler, req) async { + if (canContinue) { + canContinue = await new Future.sync(() async { + return _applyHandler(handler, req, res); + }).catchError((e, [StackTrace stackTrace]) async { + if (e is AngelHttpException) { + // Special handling for AngelHttpExceptions :) + try { + String accept = request.headers.value(HttpHeaders.ACCEPT) ?? "*/*"; + if (accept == "*/*" || + accept.contains("application/json") || + accept.contains("application/javascript")) { + res.json(e.toMap()); + } else { + await _applyHandler(_errorHandler, req, res); + } + } catch (_) { + } + } + _onError(e, stackTrace); + canContinue = false; + return false; + }); + } else + return false; + }; + + for (var handler in before) { + await execHandler(handler, req); + } + + for (Route route in routes) { + if (!canContinue) break; + if (route.matcher.hasMatch(req_url) && + (request.method == route.method || route.method == '*')) { + req.params = route.parseParameters(request.uri.toString()); + req.route = route; + + for (var handler in route.handlers) { + await execHandler(handler, req); } } + } - _finalizeResponse(request, res); - }); - }); - - router.defaultStream.listen((HttpRequest request) async { - RequestContext req = await RequestContext.from( - request, {}, this, - null); - ResponseContext res = await ResponseContext.from( - request.response, this); - on404(req, res); + for (var handler in after) { + await execHandler(handler, req); + } _finalizeResponse(request, res); }); @@ -70,39 +110,34 @@ class Angel extends Routable { else if (result != null) { res.json(result); return false; - } else return true; + } else + return res.isOpen; } if (handler is RequestHandler) { await handler(req, res); return res.isOpen; - } - - else if (handler is RawRequestHandler) { + } else if (handler is RawRequestHandler) { var result = await handler(req.underlyingRequest); if (result is bool) return result == true; else if (result != null) { res.json(result); return false; - } else return true; - } - - else if (handler is Function || handler is Future) { + } else + return true; + } else if (handler is Function || handler is Future) { var result = await handler(); if (result is bool) return result == true; else if (result != null) { res.json(result); return false; - } else return true; - } - - else if (middleware.containsKey(handler)) { + } else + return true; + } else if (middleware.containsKey(handler)) { return await _applyHandler(middleware[handler], req, res); - } - - else { + } else { res.willCloseItself = true; res.underlyingResponse.write(god.serialize(handler)); await res.underlyingResponse.close(); @@ -117,30 +152,64 @@ class Angel extends Routable { } } + String _randomString(int length) { + var rand = new Random(); + var codeUnits = new List.generate(length, (index) { + return rand.nextInt(33) + 89; + }); + + return new String.fromCharCodes(codeUnits); + } + /// Applies an [AngelConfigurer] to this instance. - void configure(AngelConfigurer configurer) { - configurer(this); + Future configure(AngelConfigurer configurer) async { + await configurer(this); } /// Starts the server. void listen({InternetAddress address, int port: 3000}) { runZoned(() async { await startServer(address, port); - }, onError: onError); + }, onError: _onError); } - /// Responds to a 404. - RequestHandler on404 = (req, res) => res.write("404 Not Found"); + @override + use(Pattern path, Routable routable) { + if (routable is Service) { + routable.app = this; + } + super.use(path, routable); + } + + onError(handler) { + _errorHandler = handler; + } /// Handles a server error. - onError(e, [StackTrace stackTrace]) { + _onError(e, [StackTrace stackTrace]) { stderr.write(e.toString()); - if (stackTrace != null) - stderr.write(stackTrace.toString()); + if (stackTrace != null) stderr.write(stackTrace.toString()); } Angel() : super() {} /// Creates an HTTPS server. - Angel.secure() : super() {} -} \ No newline at end of file + /// Provide paths to a certificate chain and server key (both .pem). + /// If no password is provided, a random one will be generated upon running + /// the server. + Angel.secure(String certificateChainPath, String serverKeyPath, + {String password}) + : super() { + _serverGenerator = (InternetAddress address, int port) async { + var certificateChain = + Platform.script.resolve('server_chain.pem').toFilePath(); + var serverKey = Platform.script.resolve('server_key.pem').toFilePath(); + var serverContext = new SecurityContext(); + serverContext.useCertificateChain(certificateChain); + serverContext.usePrivateKey(serverKey, + password: password ?? _randomString(8)); + + return await HttpServer.bindSecure(address, port, serverContext); + }; + } +} diff --git a/lib/src/http/service.dart b/lib/src/http/service.dart index a33bdafa..2f9ea3e3 100644 --- a/lib/src/http/service.dart +++ b/lib/src/http/service.dart @@ -2,53 +2,53 @@ part of angel_framework.http; /// A data store exposed to the Internet. class Service extends Routable { + /// The [Angel] app powering this service. + Angel app; /// Retrieves all resources. Future index([Map params]) { - throw new MethodNotAllowedError('find'); + throw new AngelHttpException.MethodNotAllowed(); } /// Retrieves the desired resource. Future read(id, [Map params]) { - throw new MethodNotAllowedError('get'); + throw new AngelHttpException.MethodNotAllowed(); } /// Creates a resource. Future create(Map data, [Map params]) { - throw new MethodNotAllowedError('create'); + throw new AngelHttpException.MethodNotAllowed(); } /// Modifies a resource. + Future modify(id, Map data, [Map params]) { + throw new AngelHttpException.MethodNotAllowed(); + } + + /// Overwrites a resource. Future update(id, Map data, [Map params]) { - throw new MethodNotAllowedError('update'); + throw new AngelHttpException.MethodNotAllowed(); } /// Removes the given resource. Future remove(id, [Map params]) { - throw new MethodNotAllowedError('remove'); + throw new AngelHttpException.MethodNotAllowed(); } Service() : super() { - get('/', (req, res) async => res.json(await this.index(req.query))); + get('/', (req, res) async => await this.index(req.query)); + + post('/', (req, res) async => await this.create(req.body)); + get('/:id', (req, res) async => - res.json(await this.read(req.params['id'], req.query))); - post('/', (req, res) async => res.json(await this.create(req.body))); - post('/:id', (req, res) async => - res.json(await this.update(req.params['id'], req.body))); - delete('/:id', (req, res) async => - res.json(await this.remove(req.params['id'], req.body))); + await this.read(req.params['id'], req.query)); + + patch('/:id', (req, res) async => await this.modify( + req.params['id'], req.body)); + + post('/:id', (req, res) async => await this.update( + req.params['id'], req.body)); + + delete('/:id', (req, res) async => await this.remove(req.params['id'], req.query)); } -} - -/// Thrown when an unimplemented method is called. -class MethodNotAllowedError extends Error { - /// The action that threw the error. - /// - /// Ex. 'get', 'remove' - String action; - - /// A description of this error. - String get error => 'This service does not support the "$action" action.'; - - MethodNotAllowedError(String this.action); } \ No newline at end of file diff --git a/lib/src/http/services/memory.dart b/lib/src/http/services/memory.dart index c367340c..987c5a76 100644 --- a/lib/src/http/services/memory.dart +++ b/lib/src/http/services/memory.dart @@ -5,26 +5,70 @@ class MemoryService extends Service { God god = new God(); Map items = {}; - Future index([Map params]) async => items.values.toList(); + Map makeJson(int index, T t) { + return mergeMap([god.serializeToMap(t), {'id': index}]); + } - Future read(id, [Map params]) async => items[int.parse(id)]; + Future index([Map params]) async { + return items.keys + .where((index) => items[index] != null) + .map((index) => makeJson(index, items[index])) + .toList(); + } + + Future read(id, [Map params]) async { + int desiredId = int.parse(id.toString()); + if (items.containsKey(desiredId)) { + T found = items[desiredId]; + if (found != null) { + return makeJson(desiredId, found); + } else throw new AngelHttpException.NotFound(); + } else throw new AngelHttpException.NotFound(); + } Future create(Map data, [Map params]) async { - data['id'] = items.length; - items[items.length] = god.deserializeFromMap(data, T); - return items[items.length - 1]; + try { + items[items.length] = god.deserializeFromMap(data, T); + T created = items[items.length - 1]; + return makeJson(items.length - 1, created); + } catch (e) { + throw new AngelHttpException.BadRequest(message: 'Invalid data.'); + } + } + + Future modify(id, Map data, [Map params]) async { + int desiredId = int.parse(id.toString()); + if (items.containsKey(desiredId)) { + try { + Map existing = god.serializeToMap(items[desiredId]); + data = mergeMap([existing, data]); + items[desiredId] = god.deserializeFromMap(data, T); + return makeJson(desiredId, items[desiredId]); + } catch (e) { + throw new AngelHttpException.BadRequest(message: 'Invalid data.'); + } + } else throw new AngelHttpException.NotFound(); } Future update(id, Map data, [Map params]) async { - data['id'] = int.parse(id); - items[int.parse(id)] = god.deserializeFromMap(data, T); - return data; + int desiredId = int.parse(id.toString()); + if (items.containsKey(desiredId)) { + try { + items[desiredId] = god.deserializeFromMap(data, T); + return makeJson(desiredId, items[desiredId]); + } catch (e) { + throw new AngelHttpException.BadRequest(message: 'Invalid data.'); + } + } else throw new AngelHttpException.NotFound(); } Future remove(id, [Map params]) async { - var item = items[int.parse(id)]; - items.remove(int.parse(id)); - return item; + int desiredId = int.parse(id.toString()); + if (items.containsKey(desiredId)) { + T item = items[desiredId]; + items[desiredId] = null; + return makeJson(desiredId, item); + } else throw new AngelHttpException.NotFound(); } MemoryService() : super(); diff --git a/pubspec.yaml b/pubspec.yaml index 847c8ac0..4cce9475 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,13 @@ name: angel_framework -version: 0.0.0-dev.5 +version: 0.0.0-dev.6 description: Core libraries for the Angel framework. author: Tobe O homepage: https://github.com/angel-dart/angel_framework dependencies: body_parser: ">=1.0.0-dev <2.0.0" json_god: ">=1.0.0 <2.0.0" + merge_map: ">=1.0.0 <2.0.0" mime: ">=0.9.3 <0.10.0" - route: ">= 0.4.6 <0.5.0" dev_dependencies: http: ">= 0.11.3 < 0.12.0" test: ">= 0.12.13 < 0.13.0" \ No newline at end of file diff --git a/test/routing.dart b/test/routing.dart index 7ce95cb0..cf6950e3 100644 --- a/test/routing.dart +++ b/test/routing.dart @@ -29,10 +29,11 @@ main() { angel.get('/intercepted', 'This should not be shown', middleware: ['interceptor']); angel.get('/hello', 'world'); - angel.get('/name/:first/last/:last', (req, res) => res.json(req.params)); + angel.get('/name/:first/last/:last', (req, res) => req.params); angel.post('/lambda', (req, res) => req.body); angel.use('/nes', nested); angel.use('/todos/:id', todos); + angel.get('*', 'MJ'); client = new http.Client(); await angel.startServer(InternetAddress.LOOPBACK_IP_V4, 0); @@ -87,5 +88,10 @@ main() { "$url/lambda", headers: headers, body: postData); expect(god.deserialize(response.body)['it'], equals('works')); }); + + test('Fallback routes', () async { + var response = await client.get('$url/my_favorite_artist'); + expect(response.body, equals('"MJ"')); + }); }); } \ No newline at end of file diff --git a/test/services.dart b/test/services.dart index c7d5417c..af07a06d 100644 --- a/test/services.dart +++ b/test/services.dart @@ -4,13 +4,14 @@ import 'package:json_god/json_god.dart'; import 'package:test/test.dart'; class Todo { - int id; String text; + String over; } main() { group('Services', () { Map headers = { + 'Accept': 'application/json', 'Content-Type': 'application/json' }; Angel angel; @@ -38,17 +39,80 @@ main() { group('memory', () { test('can index an empty service', () async { var response = await client.get("$url/todos/"); + print(response.body); expect(response.body, equals('[]')); + for (int i = 0; i < 3; i++) { + String postData = god.serialize({'text': 'Hello, world!'}); + await client.post( + "$url/todos", headers: headers, body: postData); + } + response = await client.get("$url/todos"); + print(response.body); + expect(god + .deserialize(response.body) + .length, equals(3)); }); test('can create data', () async { String postData = god.serialize({'text': 'Hello, world!'}); var response = await client.post( - "$url/todos/", headers: headers, body: postData); + "$url/todos", headers: headers, body: postData); var json = god.deserialize(response.body); print(json); expect(json['text'], equals('Hello, world!')); }); + + test('can fetch data', () async { + String postData = god.serialize({'text': 'Hello, world!'}); + await client.post( + "$url/todos", headers: headers, body: postData); + var response = await client.get( + "$url/todos/0"); + var json = god.deserialize(response.body); + print(json); + expect(json['text'], equals('Hello, world!')); + }); + + test('can modify data', () async { + String postData = god.serialize({'text': 'Hello, world!'}); + await client.post( + "$url/todos", headers: headers, body: postData); + postData = god.serialize({'text': 'modified'}); + var response = await client.patch( + "$url/todos/0", headers: headers, body: postData); + var json = god.deserialize(response.body); + print(json); + expect(json['text'], equals('modified')); + }); + + test('can overwrite data', () async { + String postData = god.serialize({'text': 'Hello, world!'}); + await client.post( + "$url/todos", headers: headers, body: postData); + postData = god.serialize({'over': 'write'}); + var response = await client.post( + "$url/todos/0", headers: headers, body: postData); + var json = god.deserialize(response.body); + print(json); + expect(json['text'], equals(null)); + expect(json['over'], equals('write')); + }); + + test('can delete data', () async { + String postData = god.serialize({'text': 'Hello, world!'}); + await client.post( + "$url/todos", headers: headers, body: postData); + var response = await client.delete( + "$url/todos/0"); + var json = god.deserialize(response.body); + print(json); + expect(json['text'], equals('Hello, world!')); + response = await client.get("$url/todos"); + print(response.body); + expect(god + .deserialize(response.body) + .length, equals(0)); + }); }); }); } \ No newline at end of file