diff --git a/lib/src/http/http.dart b/lib/src/http/http.dart index 3703c3bf..dd0d4a70 100644 --- a/lib/src/http/http.dart +++ b/lib/src/http/http.dart @@ -2,10 +2,18 @@ library angel_framework.http; import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:mirrors'; +import 'package:body_parser/body_parser.dart'; import 'package:json_god/json_god.dart'; +import 'package:mime/mime.dart'; import 'package:route/server.dart'; +part 'request_context.dart'; +part 'response_context.dart'; part 'route.dart'; part 'routable.dart'; -part 'server.dart'; \ No newline at end of file +part 'server.dart'; +part 'service.dart'; +part 'services/memory.dart'; \ No newline at end of file diff --git a/lib/src/http/request_context.dart b/lib/src/http/request_context.dart new file mode 100644 index 00000000..35bb7e23 --- /dev/null +++ b/lib/src/http/request_context.dart @@ -0,0 +1,88 @@ +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); + +/// A function that receives an incoming [RequestContext] and responds to it. +typedef Future RequestHandler(RequestContext req, ResponseContext res); + +/// A function that handles an [HttpRequest]. +typedef Future RawRequestHandler(HttpRequest request); + +/// A convenience wrapper around an incoming HTTP request. +class RequestContext { + /// The [Angel] instance that is responding to this request. + Angel app; + + /// Any cookies sent with this request. + List get cookies => underlyingRequest.cookies; + + /// All HTTP headers sent with this request. + HttpHeaders get headers => underlyingRequest.headers; + + /// The requested hostname. + String get hostname => underlyingRequest.headers.value(HttpHeaders.HOST); + + /// The user's IP. + String get ip => remoteAddress.address; + + /// This request's HTTP method. + String get method => underlyingRequest.method; + + /// All post data submitted to the server. + Map body = {}; + + /// The content type of an incoming request. + ContentType contentType; + + /// Any and all files sent to the server with this request. + List files = []; + + /// The URL parameters extracted from the request URI. + Map params = {}; + + /// The requested path. + String path; + + /// The parsed request query string. + Map query = {}; + + /// The remote address requesting this resource. + InternetAddress remoteAddress; + + /// The route that matched this request. + Route route; + + /// The user's HTTP session. + HttpSession session; + + /// Is this an **XMLHttpRequest**? + bool get xhr => underlyingRequest.headers.value("X-Requested-With") + ?.trim() + ?.toLowerCase() == 'xmlhttprequest'; + + /// The underlying [HttpRequest] instance underneath this context. + HttpRequest underlyingRequest; + + /// Magically transforms an [HttpRequest] into a RequestContext. + static Future from(HttpRequest request, + Map parameters, Angel app, Route sourceRoute) async { + RequestContext context = new RequestContext(); + + context.app = app; + context.contentType = request.headers.contentType; + context.remoteAddress = request.connectionInfo.remoteAddress; + context.params = parameters; + context.path = request.uri.toString(); + context.route = sourceRoute; + context.session = request.session; + context.underlyingRequest = request; + + BodyParseResult bodyParseResult = await parseBody(request); + context.query = bodyParseResult.query; + context.body = bodyParseResult.body; + context.files = bodyParseResult.files; + + return context; + } +} \ No newline at end of file diff --git a/lib/src/http/response_context.dart b/lib/src/http/response_context.dart new file mode 100644 index 00000000..7268a423 --- /dev/null +++ b/lib/src/http/response_context.dart @@ -0,0 +1,129 @@ +part of angel_framework.http; + +/// A function that asynchronously generates a view from the given path and data. +typedef Future ViewGenerator(String path, {Map data}); + +/// A convenience wrapper around an outgoing HTTP request. +class ResponseContext { + /// The [Angel] instance that is sending a response. + Angel app; + + God god = new God(); + + /// Can we still write to this response? + bool isOpen = true; + + /// A set of UTF-8 encoded bytes that will be written to the response. + List> responseData = []; + + /// Sets the status code to be sent with this response. + status(int code) { + underlyingResponse.statusCode = code; + } + + /// The underlying [HttpResponse] under this instance. + HttpResponse underlyingResponse; + + ResponseContext(this.underlyingResponse); + + /// Any and all cookies to be sent to the user. + List get cookies => underlyingResponse.cookies; + + /// Set this to true if you will manually close the response. + bool willCloseItself = false; + + /// Sends a download as a response. + download(File file, {String filename}) { + header("Content-Disposition", + 'Content-Disposition: attachment; filename="${filename ?? file.path}"'); + header(HttpHeaders.CONTENT_TYPE, lookupMimeType(file.path)); + header(HttpHeaders.CONTENT_LENGTH, file.lengthSync().toString()); + responseData.add(file.readAsBytesSync()); + } + + /// Prevents more data from being written to the response. + end() => isOpen = false; + + /// Sets a response header to the given value, or retrieves its value. + header(String key, [String value]) { + if (value == null) return underlyingResponse.headers[key]; + else underlyingResponse.headers.set(key, value); + } + + /// Serializes JSON to the response. + json(value) { + write(god.serialize(value)); + header(HttpHeaders.CONTENT_TYPE, ContentType.JSON.toString()); + end(); + } + + /// Returns a JSONP response. + jsonp(value, {String callbackName: "callback"}) { + write("$callbackName(${god.serialize(value)})"); + header(HttpHeaders.CONTENT_TYPE, "application/javascript"); + end(); + } + + /// Renders a view to the response stream, and closes the response. + Future render(String view, {Map data}) async { + /// TODO: app.viewGenerator + var generator = app.viewGenerator(view, data: data); + write(await generator); + header(HttpHeaders.CONTENT_TYPE, ContentType.HTML.toString()); + end(); + } + + /// Redirects to user to the given URL. + redirect(String url, {int code: 301}) { + header(HttpHeaders.LOCATION, url); + status(code); + write(''' + + + + Redirecting... + + + +

Currently redirecting you...

+
+ Click if you are not automatically redirected... + + + + '''); + end(); + } + + /// Streams a file to this response as chunked data. + /// + /// Useful for video sites. + streamFile(File file, + {int chunkSize, int sleepMs: 0, bool resumable: true}) async { + if (!isOpen) return; + + header(HttpHeaders.CONTENT_TYPE, lookupMimeType(file.path)); + willCloseItself = true; + await file.openRead().pipe(underlyingResponse); + /*await chunked(file.openRead(), chunkSize: chunkSize, + sleepMs: sleepMs, + resumable: resumable);*/ + } + + /// Writes data to the response. + write(value) { + if (isOpen) + responseData.add(UTF8.encode(value.toString())); + } + + /// Magically transforms an [HttpResponse] object into a ResponseContext. + static Future from + (HttpResponse response, Angel app) async + { + ResponseContext context = new ResponseContext(response); + context.app = app; + return context; + } +} \ No newline at end of file diff --git a/lib/src/http/routable.dart b/lib/src/http/routable.dart index 5f91102c..eb0cec32 100644 --- a/lib/src/http/routable.dart +++ b/lib/src/http/routable.dart @@ -1,10 +1,59 @@ part of angel_framework.http; +typedef RouteAssigner(Pattern path, handler, {List middleware}); + /// A routable server that can handle dynamic requests. class Routable { /// Additional filters to be run on designated requests. - Map middleware = {}; + Map middleware = {}; /// Dynamic request paths that this server will respond to. - Map routes = {}; + List routes = []; + + /// 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); + }; + } + + /// Assigns a middleware to a name for convenience. + registerMiddleware(String name, Middleware middleware) { + this.middleware[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); + for (Route route in routable.routes) { + Route provisional = new Route('', path); + route.matcher = new RegExp(route.matcher.pattern.replaceAll( + new RegExp('^\\^'), + provisional.matcher.pattern.replaceAll(new RegExp(r'\$$'), ''))); + route.path = "$path${route.path}"; + + routes.add(route); + } + + if (routable is Service) { + services[path.toString()] = routable; + } + } + + RouteAssigner get, post, patch, delete; + + 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/route.dart b/lib/src/http/route.dart index 669013c0..0385b091 100644 --- a/lib/src/http/route.dart +++ b/lib/src/http/route.dart @@ -1,14 +1,56 @@ part of angel_framework.http; class Route { - Pattern matcher; + RegExp matcher; String method; + List handlers = []; + String path; Route(String method, Pattern path, [List handlers]) { this.method = method; - if (path is RegExp) this.matcher = path; - else this.matcher = new RegExp('^' + - path.toString().replaceAll(new RegExp('\/'), r'\/').replaceAll( - new RegExp(':[a-zA-Z_]+'), '([^\/]+)') + r'$'); + if (path is RegExp) { + this.matcher = path; + this.path = path.pattern; + } + else { + this.matcher = new RegExp('^' + + path.toString() + .replaceAll(new RegExp('\/'), r'\/') + .replaceAll(new RegExp(':[a-zA-Z_]+'), '([^\/]+)') + .replaceAll(new RegExp('\\*'), '.*') + + r'$'); + this.path = path; + } + + if (handlers != null) { + this.handlers.addAll(handlers); + } + } + + parseParameters(String requestPath) { + Map result = {}; + + Iterable values = _parseParameters(requestPath); + RegExp rgx = new RegExp(':([a-zA-Z_]+)'); + Iterable matches = rgx.allMatches( + path.replaceAll(new RegExp('\/'), r'\/')); + for (int i = 0; i < matches.length; i++) { + Match match = matches.elementAt(i); + String paramName = match.group(1); + String value = values.elementAt(i); + num numValue = num.parse(value, (_) => double.NAN); + if (!numValue.isNaN) + result[paramName] = numValue; + else + result[paramName] = value; + } + + return result; + } + + _parseParameters(String requestPath) sync* { + Match routeMatch = matcher.firstMatch(requestPath); + for (int i = 1; i <= routeMatch.groupCount; i++) + yield routeMatch.group(i); } } diff --git a/lib/src/http/server.dart b/lib/src/http/server.dart index c98ca182..c1cf874d 100644 --- a/lib/src/http/server.dart +++ b/lib/src/http/server.dart @@ -1,39 +1,136 @@ part of angel_framework.http; -/// A function that binds +/// A function that binds an [Angel] server to an Internet address and port. typedef Future ServerGenerator(InternetAddress address, int port); +/// A function that configures an [Angel] server in some way. +typedef AngelConfigurer(Angel app); + /// A powerful real-time/REST/MVC server class. class Angel extends Routable { - ServerGenerator _serverGenerator; + ServerGenerator _serverGenerator = (address, port) async => await HttpServer + .bind(address, port); + var viewGenerator = (String view, {Map data}) => {}; - _startServer(InternetAddress address, int port) async { + HttpServer httpServer; + God god = new God(); + + /// A set of custom properties that can be assigned to the server. + /// + /// Useful for configuration and extension. + Map properties = {}; + + startServer(InternetAddress address, int port) async { var server = await _serverGenerator( address ?? InternetAddress.LOOPBACK_IP_V4, port); + this.httpServer = server; var router = new Router(server); - this.routes.forEach((Route route, value) { + this.routes.forEach((Route route) { router.serve(route.matcher, method: route.method).listen(( - HttpRequest request) { + 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; + 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; + }); + } + } + + if (!res.willCloseItself) { + res.responseData.forEach((blob) => request.response.add(blob)); + await request.response.close(); + } }); }); + + return server; + } + + Future _applyHandler(handler, RequestContext req, + ResponseContext res) async { + if (handler is Middleware) { + return await handler(req, res); + } + + else if (handler is RequestHandler) { + await handler(req, res); + return res.isOpen; + } + + else if (handler is RawRequestHandler) { + var result = await handler(req.underlyingRequest); + return result is bool && result == true; + } + + else if (handler is Function || handler is Future) { + var result = await handler(); + return result is bool && result == true; + } + + else if (middleware.containsKey(handler)) { + return await _applyHandler(middleware[handler], req, res); + } + + else { + res.willCloseItself = true; + res.underlyingResponse.write(god.serialize(handler)); + await res.underlyingResponse.close(); + return false; + } + } + + /// Applies an [AngelConfigurer] to this instance. + void configure(AngelConfigurer configurer) { + configurer(this); } /// Starts the server. void listen({InternetAddress address, int port: 3000}) { runZoned(() async { - await _startServer(address, port); + await startServer(address, port); }, onError: onError); } /// Handles a server error. - void onError(e, [StackTrace stackTrace]) { + var onError = (e, [StackTrace stackTrace]) { + stderr.write(e.toString()); + if (stackTrace != null) + stderr.write(stackTrace.toString()); + }; - } - - Angel() {} + Angel() : super() {} /// Creates an HTTPS server. - Angel.secure() {} + Angel.secure() : super() {} + + noSuchMethod(Invocation invocation) { + if (invocation.memberName != null) { + String name = MirrorSystem.getName(invocation.memberName); + if (properties.containsKey(name)) { + if (invocation.isGetter) + return properties[name]; + else if (invocation.isMethod) { + return Function.apply( + properties[name], invocation.positionalArguments, + invocation.namedArguments); + } + } + } + + super.noSuchMethod(invocation); + } + + } \ No newline at end of file diff --git a/lib/src/http/service.dart b/lib/src/http/service.dart new file mode 100644 index 00000000..0b598b50 --- /dev/null +++ b/lib/src/http/service.dart @@ -0,0 +1,54 @@ +part of angel_framework.http; + +/// A data store exposed to the Internet. +class Service extends Routable { + + /// Retrieves all resources. + Future index([Map params]) { + throw new MethodNotAllowedError('find'); + } + + /// Retrieves the desired resource. + Future read(id, [Map params]) { + throw new MethodNotAllowedError('get'); + } + + /// Creates a resource. + Future create(Map data, [Map params]) { + throw new MethodNotAllowedError('create'); + } + + /// Modifies a resource. + Future update(id, Map data, [Map params]) { + throw new MethodNotAllowedError('update'); + } + + /// Removes the given resource. + Future remove(id, [Map params]) { + throw new MethodNotAllowedError('remove'); + } + + Service() : super() { + get('/', (req, res) async => res.json(await this.index(req.query))); + 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))g); + 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))); + } +} + +/// 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 new file mode 100644 index 00000000..c367340c --- /dev/null +++ b/lib/src/http/services/memory.dart @@ -0,0 +1,31 @@ +part of angel_framework.http; + +/// An in-memory [Service]. +class MemoryService extends Service { + God god = new God(); + Map items = {}; + + Future index([Map params]) async => items.values.toList(); + + Future read(id, [Map params]) async => items[int.parse(id)]; + + Future create(Map data, [Map params]) async { + data['id'] = items.length; + items[items.length] = god.deserializeFromMap(data, T); + return items[items.length - 1]; + } + + Future update(id, Map data, [Map params]) async { + data['id'] = int.parse(id); + items[int.parse(id)] = god.deserializeFromMap(data, T); + return data; + } + + Future remove(id, [Map params]) async { + var item = items[int.parse(id)]; + items.remove(int.parse(id)); + return item; + } + + MemoryService() : super(); +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 86b44c74..ae48993a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,5 +3,10 @@ version: 0.0.1-dev description: Core libraries for the Angel framework. author: Tobe O dependencies: + body_parser: ^1.0.0-dev json_god: any - route: any \ No newline at end of file + mime: ^0.9.3 + route: any +dev_dependencies: + http: any + test: any \ No newline at end of file diff --git a/test/routing.dart b/test/routing.dart new file mode 100644 index 00000000..62eb003b --- /dev/null +++ b/test/routing.dart @@ -0,0 +1,72 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:http/http.dart' as http; +import 'package:json_god/json_god.dart'; +import 'package:test/test.dart'; + +main() { + group('routing', () { + Angel angel; + Angel nested; + Angel todos; + String url; + http.Client client; + God god; + + setUp(() async { + god = new God(); + angel = new Angel(); + nested = new Angel(); + todos = new Angel(); + + todos.get('/action/:action', (req, res) => res.json(req.params)); + + nested.post('/ted/:route', (req, res) => res.json(req.params)); + + angel.get('/hello', 'world'); + angel.get('/name/:first/last/:last', (req, res) => res.json(req.params)); + angel.use('/nes', nested); + angel.use('/todos/:id', todos); + + client = new http.Client(); + await angel.startServer(InternetAddress.LOOPBACK_IP_V4, 0); + url = "http://${angel.httpServer.address.host}:${angel.httpServer.port}"; + }); + + tearDown(() async { + await angel.httpServer.close(force: true); + angel = null; + nested = null; + todos = null; + client.close(); + client = null; + url = null; + god = null; + }); + + test('Can match basic url', () async { + var response = await client.get("$url/hello"); + expect(response.body, equals('"world"')); + }); + + test('Can match url with multiple parameters', () async { + var response = await client.get('$url/name/HELLO/last/WORLD'); + var json = god.deserialize(response.body); + expect(json['first'], equals('HELLO')); + expect(json['last'], equals('WORLD')); + }); + + test('Can nest another Angel instance', () async { + var response = await client.post('$url/nes/ted/foo'); + var json = god.deserialize(response.body); + expect(json['route'], equals('foo')); + }); + + test('Can parse parameters from a nested Angel instance', () async { + var response = await client.get('$url/todos/1337/action/test'); + var json = god.deserialize(response.body); + expect(json['id'], equals(1337)); + expect(json['action'], equals('test')); + }); + }); +} \ No newline at end of file diff --git a/test/services.dart b/test/services.dart new file mode 100644 index 00000000..11157293 --- /dev/null +++ b/test/services.dart @@ -0,0 +1,54 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:http/http.dart' as http; +import 'package:json_god/json_god.dart'; +import 'package:test/test.dart'; + +class Todo { + int id; + String text; +} + +main() { + group('Utilities', () { + Map headers = { + 'Content-Type': 'application/json' + }; + Angel angel; + String url; + http.Client client; + God god; + + setUp(() async { + angel = new Angel(); + client = new http.Client(); + god = new God(); + angel.use('/todos', new MemoryService()); + await angel.startServer(null, 0); + url = "http://${angel.httpServer.address.host}:${angel.httpServer.port}"; + }); + + tearDown(() async { + angel = null; + url = null; + client.close(); + client = null; + god = null; + }); + + group('memory', () { + test('can index an empty service', () async { + var response = await client.get("$url/todos/"); + expect(response.body, equals('[]')); + }); + + test('can create data', () async { + String postData = god.serialize({'text': 'Hello, world!'}); + var response = await client.post( + "$url/todos/", headers: headers, body: postData); + var json = god.deserialize(response.body); + print(json); + expect(json['text'], equals('Hello, world!')); + }); + }); + }); +} \ No newline at end of file diff --git a/test/util.dart b/test/util.dart new file mode 100644 index 00000000..6e641c0a --- /dev/null +++ b/test/util.dart @@ -0,0 +1,32 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:test/test.dart'; + +class Foo { + String name; + + Foo(String this.name); +} + +main() { + group('Utilities', () { + Angel angel; + + setUp(() { + angel = new Angel(); + }); + + tearDown(() { + angel = null; + }); + + test('can use app.properties like members', () { + angel.properties['hello'] = 'world'; + angel.properties['foo'] = () => 'bar'; + angel.properties['Foo'] = new Foo('bar'); + + expect(angel.hello, equals('world')); + expect(angel.foo(), equals('bar')); + expect(angel.Foo.name, equals('bar')); + }); + }); +} \ No newline at end of file