diff --git a/README.md b/README.md index 6d6af8f8..afa70e62 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # angel_framework -[![pub 1.0.0-dev.68](https://img.shields.io/badge/pub-1.0.0--dev.68-red.svg)](https://pub.dartlang.org/packages/angel_framework) +[![pub 1.0.0-dev.69](https://img.shields.io/badge/pub-1.0.0--dev.69-red.svg)](https://pub.dartlang.org/packages/angel_framework) [![build status](https://travis-ci.org/angel-dart/framework.svg)](https://travis-ci.org/angel-dart/framework) Core libraries for the Angel Framework. diff --git a/example/common.dart b/example/common.dart new file mode 100644 index 00000000..c47d469d --- /dev/null +++ b/example/common.dart @@ -0,0 +1,5 @@ +import 'dart:async'; +import 'dart:io'; + +Future startShared(InternetAddress address, int port) => HttpServer + .bind(address ?? InternetAddress.LOOPBACK_IP_V4, port ?? 0, shared: true); diff --git a/example/json.dart b/example/json.dart new file mode 100644 index 00000000..d84278e5 --- /dev/null +++ b/example/json.dart @@ -0,0 +1,52 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'package:angel_framework/angel_framework.dart'; +import 'common.dart'; + +main() async { + int x = 0; + var c = new Completer(); + var exit = new ReceivePort(); + List isolates = []; + + exit.listen((_) { + if (++x >= 50) { + c.complete(); + } + }); + + for (int i = 0; i < 50; i++) { + var isolate = await Isolate.spawn(serverMain, null); + isolates.add(isolate); + print('Spawned isolate #${i + 1}...'); + + isolate.addOnExitListener(exit.sendPort); + } + + print('Angel listening at http://localhost:3000'); + await c.future; +} + +serverMain(_) async { + var app = new Angel.custom(startShared); // Run a cluster + + app.get('/', { + "foo": "bar", + "one": [2, "three"], + "bar": {"baz": "quux"} + }); + + // Performance tuning + app + ..lazyParseBodies = true + ..injectSerializer(JSON.encode); + + app.fatalErrorStream.listen((e) { + print(e.error); + print(e.stack); + }); + + await app.startServer(InternetAddress.LOOPBACK_IP_V4, 3000); +} diff --git a/lib/hooks.dart b/lib/hooks.dart index c8b2430d..684abc6b 100644 --- a/lib/hooks.dart +++ b/lib/hooks.dart @@ -32,20 +32,8 @@ AngelConfigurer hookAllServices(callback(Service service)) { }; } -/// Transforms `e.data` or `e.result` into JSON-friendly data, i.e. a Map. -HookedServiceEventListener toJson() { - return (HookedServiceEvent e) { - normalize(obj) { - if (obj != null && obj is! Map) return god.serializeObject(obj); - return obj; - } - - if (e.isBefore) { - e.data = normalize(e.data); - } else - e.result = normalize(e.result); - }; -} +/// Transforms `e.data` or `e.result` into JSON-friendly data, i.e. a Map. Runs on Iterables as well. +HookedServiceEventListener toJson() => transform(god.serializeObject); /// Mutates `e.data` or `e.result` using the given [transformer]. HookedServiceEventListener transform(transformer(obj)) { @@ -61,7 +49,7 @@ HookedServiceEventListener transform(transformer(obj)) { return (HookedServiceEvent e) { if (e.isBefore) { e.data = normalize(e.data); - } else + } else if (e.isAfter) e.result = normalize(e.result); }; } @@ -134,8 +122,13 @@ HookedServiceEventListener remove(key, [remover(key, obj)]) { } } - if (e.params?.containsKey('provider') == true) - await normalize(e.isBefore ? e.data : e.result); + if (e.params?.containsKey('provider') == true) { + if (e.isBefore) { + e.data = await normalize(e.data); + } else if (e.isAfter) { + e.result = await normalize(e.result); + } + } }; } @@ -168,3 +161,90 @@ HookedServiceEventListener disable([provider]) { } }; } + +/// Serializes the current time to `e.data` or `e.result`. +/// You can provide an [assign] function to set the property on your object, and skip reflection. +/// +/// Default key: `createdAt` +HookedServiceEventListener addCreatedAt({ + assign(obj, String now), + String key, +}) { + var name = key?.isNotEmpty == true ? key : 'createdAt'; + + return (HookedServiceEvent e) async { + _assign(obj, String now) { + if (assign != null) + return assign(obj, now); + else if (obj is Map) + obj.remove(name); + else if (obj is Extensible) + obj..properties.remove(name); + else { + try { + reflect(obj).setField(new Symbol(name), now); + } catch (e) { + throw new ArgumentError("Cannot set key '$name' on $obj."); + } + } + } + + var now = new DateTime.now().toIso8601String(); + + normalize(obj) async { + if (obj != null) { + if (obj is Iterable) { + obj.forEach(normalize); + } else { + await _assign(obj, now); + } + } + } + + if (e.params?.containsKey('provider') == true) + await normalize(e.isBefore ? e.data : e.result); + }; +} + +/// Serializes the current time to `e.data` or `e.result`. +/// You can provide an [assign] function to set the property on your object, and skip reflection./// +/// Default key: `createdAt` +HookedServiceEventListener addUpatedAt({ + assign(obj, String now), + String key, +}) { + var name = key?.isNotEmpty == true ? key : 'updatedAt'; + + return (HookedServiceEvent e) async { + _assign(obj, String now) { + if (assign != null) + return assign(obj, now); + else if (obj is Map) + obj.remove(name); + else if (obj is Extensible) + obj..properties.remove(name); + else { + try { + reflect(obj).setField(new Symbol(name), now); + } catch (e) { + throw new ArgumentError("Cannot SET key '$name' ON $obj."); + } + } + } + + var now = new DateTime.now().toIso8601String(); + + normalize(obj) async { + if (obj != null) { + if (obj is Iterable) { + obj.forEach(normalize); + } else { + await _assign(obj, now); + } + } + } + + if (e.params?.containsKey('provider') == true) + await normalize(e.isBefore ? e.data : e.result); + }; +} diff --git a/lib/src/http/angel_base.dart b/lib/src/http/angel_base.dart index 8bd3f67b..60b4124c 100644 --- a/lib/src/http/angel_base.dart +++ b/lib/src/http/angel_base.dart @@ -13,7 +13,12 @@ class AngelBase extends Routable { Container _container = new Container(); - /// When set to true, the original body bytes will be stored + /// When set to true, the request body will not be parsed + /// automatically. You can call `req.parse()` manually, + /// or use `lazyBody()`. + bool lazyParseBodies = false; + + /// When set to `true`, the original body bytes will be stored /// on requests. `false` by default. bool storeOriginalBuffer = false; diff --git a/lib/src/http/controller.dart b/lib/src/http/controller.dart index 044960ad..88927505 100644 --- a/lib/src/http/controller.dart +++ b/lib/src/http/controller.dart @@ -31,15 +31,26 @@ class InjectionRequest { class Controller { Angel _app; + /// The [Angel] application powering this controller. Angel get app => _app; + final bool debug; + + /// If `true` (default), this class will inject itself as a singleton into the [app]'s container when bootstrapped. + final bool injectSingleton; + + /// Middleware to run before all handlers in this class. List middleware = []; + + /// A mapping of route paths to routes, produced from the [Expose] annotations on this class. Map routeMappings = {}; - Controller({this.debug: false}); + Controller({this.debug: false, this.injectSingleton: true}); Future call(Angel app) async { - _app = app..container.singleton(this); + _app = app; + + if (injectSingleton != false) _app.container.singleton(this); // Load global expose decl ClassMirror classMirror = reflectClass(this.runtimeType); diff --git a/lib/src/http/hooked_service.dart b/lib/src/http/hooked_service.dart index 5e4b5e7a..9cd3eb1f 100644 --- a/lib/src/http/hooked_service.dart +++ b/lib/src/http/hooked_service.dart @@ -3,6 +3,7 @@ library angel_framework.http; import 'dart:async'; import 'package:merge_map/merge_map.dart'; import '../util.dart'; +import 'angel_http_exception.dart'; import 'request_context.dart'; import 'response_context.dart'; import 'metadata.dart'; @@ -128,15 +129,18 @@ class HookedService extends Service { ..addAll((indexMiddleware == null) ? [] : indexMiddleware.handlers)); Middleware createMiddleware = getAnnotation(inner.create, Middleware); - post( - '/', - (req, res) async => await this.create( - req.body, - mergeMap([ - {'query': req.query}, - restProvider, - req.serviceParams - ])), + + post('/', (req, res) async { + var r = await this.create( + await req.lazyBody(), + mergeMap([ + {'query': req.query}, + restProvider, + req.serviceParams + ])); + res.statusCode = 201; + return r; + }, middleware: [] ..addAll(handlers) ..addAll( @@ -162,7 +166,7 @@ class HookedService extends Service { '/:id', (req, res) async => await this.modify( toId(req.params['id']), - req.body, + await req.lazyBody(), mergeMap([ {'query': req.query}, restProvider, @@ -178,7 +182,21 @@ class HookedService extends Service { '/:id', (req, res) async => await this.update( toId(req.params['id']), - req.body, + await req.lazyBody(), + mergeMap([ + {'query': req.query}, + restProvider, + req.serviceParams + ])), + middleware: [] + ..addAll(handlers) + ..addAll( + (updateMiddleware == null) ? [] : updateMiddleware.handlers)); + put( + '/:id', + (req, res) async => await this.update( + toId(req.params['id']), + await req.lazyBody(), mergeMap([ {'query': req.query}, restProvider, @@ -190,6 +208,19 @@ class HookedService extends Service { (updateMiddleware == null) ? [] : updateMiddleware.handlers)); Middleware removeMiddleware = getAnnotation(inner.remove, Middleware); + delete( + '/', + (req, res) async => await this.remove( + null, + mergeMap([ + {'query': req.query}, + restProvider, + req.serviceParams + ])), + middleware: [] + ..addAll(handlers) + ..addAll( + (removeMiddleware == null) ? [] : removeMiddleware.handlers)); delete( '/:id', (req, res) async => await this.remove( @@ -204,6 +235,10 @@ class HookedService extends Service { ..addAll( (removeMiddleware == null) ? [] : removeMiddleware.handlers)); + // REST compliance + put('/', () => throw new AngelHttpException.notFound()); + patch('/', () => throw new AngelHttpException.notFound()); + addHooks(); } @@ -522,7 +557,7 @@ class HookedServiceEvent { } /// Resolves a service from the application. - /// + /// /// Shorthand for `e.service.app.service(...)`. Service getService(Pattern path) => service.app.service(path); diff --git a/lib/src/http/map_service.dart b/lib/src/http/map_service.dart index d5a1bd69..d06fbf73 100644 --- a/lib/src/http/map_service.dart +++ b/lib/src/http/map_service.dart @@ -65,6 +65,8 @@ class MapService extends Service { throw new AngelHttpException.badRequest( message: 'MapService does not support `modify` with ${data.runtimeType}.'); + if (!items.any(_matchesId(id))) return await create(data, params); + var item = await read(id); return item ..addAll(data) @@ -77,9 +79,7 @@ class MapService extends Service { throw new AngelHttpException.badRequest( message: 'MapService does not support `update` with ${data.runtimeType}.'); - if (!items.any(_matchesId(id))) - throw new AngelHttpException.notFound( - message: 'No record found for ID $id'); + if (!items.any(_matchesId(id))) return await create(data, params); var old = await read(id); diff --git a/lib/src/http/request_context.dart b/lib/src/http/request_context.dart index 6d5f10b6..805778e3 100644 --- a/lib/src/http/request_context.dart +++ b/lib/src/http/request_context.dart @@ -45,17 +45,50 @@ class RequestContext extends Extensible { /// The original HTTP verb sent to the server. String get originalMethod => io.method; + StateError _unparsed(String type, String caps) => new StateError( + 'Cannot get the $type of an unparsed request. Use lazy${caps}() instead.'); + /// All post data submitted to the server. - Map get body => _body.body; + /// + /// If you are lazy-parsing request bodies, but have not manually [parse]d this one, + /// then an error will be thrown. + /// + /// **If you are writing a plug-in, use [lazyBody] instead.** + Map get body { + if (_body == null) + throw _unparsed('body', 'Body'); + else + return _body.body; + } /// The content type of an incoming request. ContentType get contentType => _contentType; /// Any and all files sent to the server with this request. - List get files => _body.files; + /// + /// If you are lazy-parsing request bodies, but have not manually [parse]d this one, + /// then an error will be thrown. + /// + /// **If you are writing a plug-in, use [lazyFiles] instead.** + List get files { + if (_body == null) + throw _unparsed('query', 'Files'); + else + return _body.files; + } /// The original body bytes sent with this request. May be empty. - List get originalBuffer => _body.originalBuffer ?? []; + /// + /// If you are lazy-parsing request bodies, but have not manually [parse]d this one, + /// then an error will be thrown. + /// + /// **If you are writing a plug-in, use [lazyOriginalBuffer] instead.** + List get originalBuffer { + if (_body == null) + throw _unparsed('original buffer', 'OriginalBuffer'); + else + return _body.originalBuffer ?? []; + } /// The URL parameters extracted from the request URI. Map params = {}; @@ -64,7 +97,17 @@ class RequestContext extends Extensible { String get path => _path; /// The parsed request query string. - Map get query => _body.query; + /// + /// If you are lazy-parsing request bodies, but have not manually [parse]d this one, + /// then [uri].query will be returned. + /// + /// **If you are writing a plug-in, consider using [lazyQuery] instead.** + Map get query { + if (_body == null) + return uri.queryParameters; + else + return _body.query; + } /// The remote address requesting this resource. InternetAddress get remoteAddress => io.connectionInfo.remoteAddress; @@ -106,9 +149,10 @@ class RequestContext extends Extensible { .replaceAll(new RegExp(r'/+$'), ''); ctx._io = request; - ctx._body = (await parseBody(request, - storeOriginalBuffer: app.storeOriginalBuffer == true)) ?? - {}; + if (app.lazyParseBodies != true) + ctx._body = (await parseBody(request, + storeOriginalBuffer: app.storeOriginalBuffer == true)) ?? + {}; return ctx; } @@ -133,7 +177,39 @@ class RequestContext extends Extensible { return null; } + /// Shorthand to add to [injections]. void inject(type, value) { injections[type] = value; } + + /// Retrieves the request body if it has already been parsed, or lazy-parses it before returning the body. + Future lazyBody() => parse().then((b) => b.body); + + /// Retrieves the request files if it has already been parsed, or lazy-parses it before returning the files. + Future> lazyFiles() => parse().then((b) => b.files); + + /// Retrieves the request files if it has already been parsed, or lazy-parses it before returning the files. + /// + /// This will return an empty `List` if you have not enabled `storeOriginalBuffer` on your [app] instance. + Future> lazyOriginalBuffer() => + parse().then((b) => b.originalBuffer); + + /// Retrieves the request body if it has already been parsed, or lazy-parses it before returning the query. + /// + /// If [forceParse] is not `true`, then [uri].query will be returned, and no parsing will be performed. + Future> lazyQuery({bool forceParse: false}) { + if (_body == null && forceParse != true) + return new Future.value(uri.query); + else + return parse().then((b) => b.query); + } + + /// Manually parses the request body, if it has not already been parsed. + Future parse() async { + if (_body != null) + return _body; + else + return _body = await parseBody(io, + storeOriginalBuffer: app.storeOriginalBuffer == true); + } } diff --git a/lib/src/http/response_context.dart b/lib/src/http/response_context.dart index 25fe5b2e..38f17a83 100644 --- a/lib/src/http/response_context.dart +++ b/lib/src/http/response_context.dart @@ -20,6 +20,8 @@ typedef String ResponseSerializer(data); /// A convenience wrapper around an outgoing HTTP request. class ResponseContext extends Extensible { + final _LockableBytesBuilder _buffer = new _LockableBytesBuilder(); + final Map _headers = {HttpHeaders.SERVER: 'angel'}; bool _isOpen = true; /// The [Angel] instance that is sending a response. @@ -32,11 +34,27 @@ class ResponseContext extends Extensible { final List cookies = []; /// Headers that will be sent to the user. - final Map headers = {HttpHeaders.SERVER: 'angel'}; + /// + /// If the response is closed, then this getter will return an immutable `Map`. + Map get headers { + if (!_isOpen) + return new Map.unmodifiable(_headers); + else + return _headers; + } /// Serializes response data into a String. /// - /// The default is conversion into JSON. + /// The default is conversion into JSON via `package:json_god`. + /// + /// If you are 100% sure that your response handlers will only + /// be JSON-encodable objects (i.e. primitives, `List`s and `Map`s), + /// then consider setting [serializer] to `JSON.encode`. + /// + /// To set it globally for the whole [app], use the following helper: + /// ```dart + /// app.injectSerializer(JSON.encode); + /// ``` ResponseSerializer serializer = god.serialize; /// This response's status code. @@ -46,7 +64,7 @@ class ResponseContext extends Extensible { bool get isOpen => _isOpen; /// A set of UTF-8 encoded bytes that will be written to the response. - final BytesBuilder buffer = new BytesBuilder(); + BytesBuilder get buffer => _buffer; /// Sets the status code to be sent with this response. @Deprecated('Please use `statusCode=` instead.') @@ -91,8 +109,12 @@ class ResponseContext extends Extensible { /// If `true`, all response finalizers will be skipped. bool willCloseItself = false; + StateError _closed() => new StateError('Cannot modify a closed response.'); + /// Sends a download as a response. download(File file, {String filename}) async { + if (!_isOpen) throw _closed(); + headers["Content-Disposition"] = 'attachment; filename="${filename ?? file.path}"'; headers[HttpHeaders.CONTENT_TYPE] = lookupMimeType(file.path); @@ -103,6 +125,7 @@ class ResponseContext extends Extensible { /// Prevents more data from being written to the response. void end() { + _buffer._lock(); _isOpen = false; } @@ -119,14 +142,24 @@ class ResponseContext extends Extensible { void json(value) => serialize(value, contentType: ContentType.JSON); /// Returns a JSONP response. - void jsonp(value, {String callbackName: "callback"}) { - write("$callbackName(${god.serialize(value)})"); - headers[HttpHeaders.CONTENT_TYPE] = "application/javascript"; + void jsonp(value, {String callbackName: "callback", contentType}) { + if (!_isOpen) throw _closed(); + write("$callbackName(${serializer(value)})"); + + if (contentType != null) { + if (contentType is ContentType) + this.contentType = contentType; + else + headers[HttpHeaders.CONTENT_TYPE] = contentType.toString(); + } else + headers[HttpHeaders.CONTENT_TYPE] = 'application/javascript'; + end(); } /// Renders a view to the response stream, and closes the response. Future render(String view, [Map data]) async { + if (!_isOpen) throw _closed(); write(await app.viewGenerator(view, data)); headers[HttpHeaders.CONTENT_TYPE] = ContentType.HTML.toString(); end(); @@ -140,6 +173,7 @@ class ResponseContext extends Extensible { /// /// See [Router]#navigate for more. :) void redirect(url, {bool absolute: true, int code: 302}) { + if (!_isOpen) throw _closed(); headers[HttpHeaders.LOCATION] = url is String ? url : app.navigate(url, absolute: absolute); statusCode = code ?? 302; @@ -165,6 +199,7 @@ class ResponseContext extends Extensible { /// Redirects to the given named [Route]. void redirectTo(String name, [Map params, int code]) { + if (!_isOpen) throw _closed(); Route _findRoute(Router r) { for (Route route in r.routes) { if (route is SymlinkRoute) { @@ -189,6 +224,7 @@ class ResponseContext extends Extensible { /// Redirects to the given [Controller] action. void redirectToAction(String action, [Map params, int code]) { + if (!_isOpen) throw _closed(); // UserController@show List split = action.split("@"); @@ -218,7 +254,7 @@ class ResponseContext extends Extensible { /// Copies a file's contents into the response buffer. Future sendFile(File file, {int chunkSize, int sleepMs: 0, bool resumable: true}) async { - if (!isOpen) return; + if (!_isOpen) throw _closed(); headers[HttpHeaders.CONTENT_TYPE] = lookupMimeType(file.path); buffer.add(await file.readAsBytes()); @@ -229,9 +265,10 @@ class ResponseContext extends Extensible { /// /// [contentType] can be either a [String], or a [ContentType]. void serialize(value, {contentType}) { + if (!_isOpen) throw _closed(); var text = serializer(value); write(text); - + if (contentType is String) headers[HttpHeaders.CONTENT_TYPE] = contentType; else if (contentType is ContentType) this.contentType = contentType; @@ -247,7 +284,7 @@ class ResponseContext extends Extensible { int sleepMs: 0, bool resumable: true, Codec, List> codec}) async { - if (!isOpen) return; + if (!_isOpen) throw _closed(); headers[HttpHeaders.CONTENT_TYPE] = lookupMimeType(file.path); end(); @@ -261,7 +298,9 @@ class ResponseContext extends Extensible { /// Writes data to the response. void write(value, {Encoding encoding: UTF8}) { - if (isOpen) { + if (!_isOpen) + throw _closed(); + else { if (value is List) buffer.add(value); else @@ -269,3 +308,73 @@ class ResponseContext extends Extensible { } } } + +abstract class _LockableBytesBuilder extends BytesBuilder { + factory _LockableBytesBuilder() => new _LockableBytesBuilderImpl(); + void _lock(); +} + +class _LockableBytesBuilderImpl implements _LockableBytesBuilder { + bool _closed = false; + final List _data = []; + + StateError _deny() => + new StateError('Cannot modified a closed response\'s buffer.'); + + @override + void _lock() { + _closed = true; + } + + @override + void add(List bytes) { + if (_closed) + throw _deny(); + else { + _data.addAll(bytes); + } + } + + @override + void addByte(int byte) { + if (_closed) + throw _deny(); + else { + _data.add(byte); + } + } + + @override + void clear() { + if (_closed) + throw _deny(); + else { + _data.clear(); + } + } + + @override + bool get isEmpty => _data.isEmpty; + + @override + bool get isNotEmpty => _data.isNotEmpty; + + @override + int get length => _data.length; + + @override + List takeBytes() { + if (_closed) + return toBytes(); + else { + var r = new List.from(_data); + clear(); + return r; + } + } + + @override + List toBytes() { + return _data; + } +} diff --git a/lib/src/http/routable.dart b/lib/src/http/routable.dart index 1ab029d1..53ac7756 100644 --- a/lib/src/http/routable.dart +++ b/lib/src/http/routable.dart @@ -19,19 +19,12 @@ 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); -/// Sequentially runs a list of [handlers] of middleware, and breaks if any does not +/// Sequentially runs a list of [handlers] of middleware, and returns early if any does not /// return `true`. Works well with [Router].chain. RequestMiddleware waterfall(List handlers) { - for (var handler in handlers) { - if (handler is! RequestMiddleware && handler is! RequestHandler) - throw new ArgumentError( - '`waterfall` only accepts middleware and handlers. $handler is not a valid option.'); - } - - return (req, res) async { + return (RequestContext req, res) async { for (var handler in handlers) { - var result = await handler(req, res); - + var result = await req.app.executeHandler(handler, req, res); if (result != true) return result; } diff --git a/lib/src/http/server.dart b/lib/src/http/server.dart index 08b900dc..53b73672 100644 --- a/lib/src/http/server.dart +++ b/lib/src/http/server.dart @@ -63,6 +63,9 @@ class Angel extends AngelBase { /// `'production'`. bool get isProduction => Platform.environment['ANGEL_ENV'] == 'production'; + /// The function used to bind this instance to an HTTP server. + ServerGenerator get serverGenerator => _serverGenerator; + /// Fired whenever a controller is added to this instance. /// /// **NOTE**: This is a broadcast stream. @@ -78,7 +81,8 @@ class Angel extends AngelBase { /// Always run before responses are sent. /// - /// These will only not run if an [AngelFatalError] occurs. + /// These will only not run if an [AngelFatalError] occurs, + /// or if a response's `willCloseItself` is set to `true`. final List responseFinalizers = []; /// The handler currently configured to run on [AngelHttpException]s. diff --git a/lib/src/http/service.dart b/lib/src/http/service.dart index 1c6f620c..5cee5774 100644 --- a/lib/src/http/service.dart +++ b/lib/src/http/service.dart @@ -1,6 +1,7 @@ library angel_framework.http.service; import 'dart:async'; +import 'package:angel_framework/src/http/response_context.dart'; import 'package:merge_map/merge_map.dart'; import '../util.dart'; import 'angel_base.dart'; @@ -100,15 +101,17 @@ class Service extends Routable { ..addAll((indexMiddleware == null) ? [] : indexMiddleware.handlers)); Middleware createMiddleware = getAnnotation(this.create, Middleware); - post( - '/', - (req, res) async => await this.create( - req.body, - mergeMap([ - {'query': req.query}, - restProvider, - req.serviceParams - ])), + post('/', (req, ResponseContext res) async { + var r = await this.create( + await req.lazyBody(), + mergeMap([ + {'query': req.query}, + restProvider, + req.serviceParams + ])); + res.statusCode = 201; + return r; + }, middleware: [] ..addAll(handlers) ..addAll( @@ -134,7 +137,7 @@ class Service extends Routable { '/:id', (req, res) async => await this.modify( toId(req.params['id']), - req.body, + await req.lazyBody(), mergeMap([ {'query': req.query}, restProvider, @@ -150,7 +153,21 @@ class Service extends Routable { '/:id', (req, res) async => await this.update( toId(req.params['id']), - req.body, + await req.lazyBody(), + mergeMap([ + {'query': req.query}, + restProvider, + req.serviceParams + ])), + middleware: [] + ..addAll(handlers) + ..addAll( + (updateMiddleware == null) ? [] : updateMiddleware.handlers)); + put( + '/:id', + (req, res) async => await this.update( + toId(req.params['id']), + await req.lazyBody(), mergeMap([ {'query': req.query}, restProvider, @@ -162,6 +179,19 @@ class Service extends Routable { (updateMiddleware == null) ? [] : updateMiddleware.handlers)); Middleware removeMiddleware = getAnnotation(this.remove, Middleware); + delete( + '/', + (req, res) async => await this.remove( + null, + mergeMap([ + {'query': req.query}, + restProvider, + req.serviceParams + ])), + middleware: [] + ..addAll(handlers) + ..addAll( + (removeMiddleware == null) ? [] : removeMiddleware.handlers)); delete( '/:id', (req, res) async => await this.remove( @@ -175,6 +205,10 @@ class Service extends Routable { ..addAll(handlers) ..addAll( (removeMiddleware == null) ? [] : removeMiddleware.handlers)); + + // REST compliance + put('/', () => throw new AngelHttpException.notFound()); + patch('/', () => throw new AngelHttpException.notFound()); } /// Invoked when this service is wrapped within a [HookedService]. diff --git a/pubspec.yaml b/pubspec.yaml index e10fa460..547697e1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: angel_framework -version: 1.0.0-dev.68 +version: 1.0.0-dev.69 description: Core libraries for the Angel framework. author: Tobe O homepage: https://github.com/angel-dart/angel_framework diff --git a/test/routing_test.dart b/test/routing_test.dart index 9a302949..070dd23a 100644 --- a/test/routing_test.dart +++ b/test/routing_test.dart @@ -59,7 +59,7 @@ main() { middleware: ['interceptor']); app.get('/hello', 'world'); app.get('/name/:first/last/:last', (req, res) => req.params); - app.post('/lambda', (req, res) => req.body); + app.post('/lambda', (RequestContext req, res) => req.lazyBody()); app.use('/todos/:id', todos); app .get('/greet/:name', diff --git a/test/services_test.dart b/test/services_test.dart index 080deab9..ee376cbf 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -56,6 +56,7 @@ main() { String postData = god.serialize({'text': 'Hello, world!'}); var response = await client.post("$url/todos", headers: headers, body: postData); + expect(response.statusCode, 201); var json = god.deserialize(response.body); print(json); expect(json['text'], equals('Hello, world!')); @@ -65,6 +66,7 @@ main() { String postData = god.serialize({'text': 'Hello, world!'}); await client.post("$url/todos", headers: headers, body: postData); var response = await client.get("$url/todos/0"); + expect(response.statusCode, 200); var json = god.deserialize(response.body); print(json); expect(json['text'], equals('Hello, world!')); @@ -76,6 +78,7 @@ main() { postData = god.serialize({'text': 'modified'}); var response = await client.patch("$url/todos/0", headers: headers, body: postData); + expect(response.statusCode, 200); var json = god.deserialize(response.body); print(json); expect(json['text'], equals('modified')); @@ -87,6 +90,7 @@ main() { postData = god.serialize({'over': 'write'}); var response = await client.post("$url/todos/0", headers: headers, body: postData); + expect(response.statusCode, 200); var json = god.deserialize(response.body); print(json); expect(json['text'], equals(null)); @@ -97,6 +101,7 @@ main() { String postData = god.serialize({'text': 'Hello, world!'}); await client.post("$url/todos", headers: headers, body: postData); var response = await client.delete("$url/todos/0"); + expect(response.statusCode, 200); var json = god.deserialize(response.body); print(json); expect(json['text'], equals('Hello, world!'));