Almost done.

This commit is contained in:
thosakwe 2016-04-17 23:27:23 -04:00
parent 31607686be
commit 95369495e4
12 changed files with 681 additions and 20 deletions

View file

@ -2,10 +2,18 @@
library angel_framework.http; library angel_framework.http;
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:mirrors';
import 'package:body_parser/body_parser.dart';
import 'package:json_god/json_god.dart'; import 'package:json_god/json_god.dart';
import 'package:mime/mime.dart';
import 'package:route/server.dart'; import 'package:route/server.dart';
part 'request_context.dart';
part 'response_context.dart';
part 'route.dart'; part 'route.dart';
part 'routable.dart'; part 'routable.dart';
part 'server.dart'; part 'server.dart';
part 'service.dart';
part 'services/memory.dart';

View file

@ -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<bool> 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<Cookie> 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<FileUploadInfo> 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<RequestContext> 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;
}
}

View file

@ -0,0 +1,129 @@
part of angel_framework.http;
/// A function that asynchronously generates a view from the given path and data.
typedef Future<String> 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<List<int>> 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<Cookie> 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('''
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
<meta http-equiv="refresh" content="0; url=$url">
</head>
<body>
<h1>Currently redirecting you...</h1>
<br />
Click <a href="$url"></a> if you are not automatically redirected...
<script>
window.location = "$url";
</script>
</body>
</html>
''');
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<ResponseContext> from
(HttpResponse response, Angel app) async
{
ResponseContext context = new ResponseContext(response);
context.app = app;
return context;
}
}

View file

@ -1,10 +1,59 @@
part of angel_framework.http; part of angel_framework.http;
typedef RouteAssigner(Pattern path, handler, {List middleware});
/// A routable server that can handle dynamic requests. /// A routable server that can handle dynamic requests.
class Routable { class Routable {
/// Additional filters to be run on designated requests. /// Additional filters to be run on designated requests.
Map <String, Object> middleware = {}; Map <String, Middleware> middleware = {};
/// Dynamic request paths that this server will respond to. /// Dynamic request paths that this server will respond to.
Map <Route, Object> routes = {}; List<Route> routes = [];
/// A set of [Service] objects that have been mapped into routes.
Map <Pattern, Service> 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');
}
} }

View file

@ -1,14 +1,56 @@
part of angel_framework.http; part of angel_framework.http;
class Route { class Route {
Pattern matcher; RegExp matcher;
String method; String method;
List handlers = [];
String path;
Route(String method, Pattern path, [List handlers]) { Route(String method, Pattern path, [List handlers]) {
this.method = method; this.method = method;
if (path is RegExp) this.matcher = path; if (path is RegExp) {
else this.matcher = new RegExp('^' + this.matcher = path;
path.toString().replaceAll(new RegExp('\/'), r'\/').replaceAll( this.path = path.pattern;
new RegExp(':[a-zA-Z_]+'), '([^\/]+)') + r'$'); }
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<String> values = _parseParameters(requestPath);
RegExp rgx = new RegExp(':([a-zA-Z_]+)');
Iterable<Match> 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);
} }
} }

View file

@ -1,39 +1,136 @@
part of angel_framework.http; part of angel_framework.http;
/// A function that binds /// A function that binds an [Angel] server to an Internet address and port.
typedef Future<HttpServer> ServerGenerator(InternetAddress address, int port); typedef Future<HttpServer> 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. /// A powerful real-time/REST/MVC server class.
class Angel extends Routable { 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( var server = await _serverGenerator(
address ?? InternetAddress.LOOPBACK_IP_V4, port); address ?? InternetAddress.LOOPBACK_IP_V4, port);
this.httpServer = server;
var router = new Router(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(( 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<bool>.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<bool> _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. /// Starts the server.
void listen({InternetAddress address, int port: 3000}) { void listen({InternetAddress address, int port: 3000}) {
runZoned(() async { runZoned(() async {
await _startServer(address, port); await startServer(address, port);
}, onError: onError); }, onError: onError);
} }
/// Handles a server error. /// 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() : super() {}
Angel() {}
/// Creates an HTTPS server. /// 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);
}
} }

54
lib/src/http/service.dart Normal file
View file

@ -0,0 +1,54 @@
part of angel_framework.http;
/// A data store exposed to the Internet.
class Service extends Routable {
/// Retrieves all resources.
Future<List> index([Map params]) {
throw new MethodNotAllowedError('find');
}
/// Retrieves the desired resource.
Future<Object> read(id, [Map params]) {
throw new MethodNotAllowedError('get');
}
/// Creates a resource.
Future<Object> create(Map data, [Map params]) {
throw new MethodNotAllowedError('create');
}
/// Modifies a resource.
Future<Object> update(id, Map data, [Map params]) {
throw new MethodNotAllowedError('update');
}
/// Removes the given resource.
Future<Object> 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);
}

View file

@ -0,0 +1,31 @@
part of angel_framework.http;
/// An in-memory [Service].
class MemoryService<T> extends Service {
God god = new God();
Map <int, T> items = {};
Future<List> index([Map params]) async => items.values.toList();
Future<Object> read(id, [Map params]) async => items[int.parse(id)];
Future<Object> create(Map data, [Map params]) async {
data['id'] = items.length;
items[items.length] = god.deserializeFromMap(data, T);
return items[items.length - 1];
}
Future<Object> update(id, Map data, [Map params]) async {
data['id'] = int.parse(id);
items[int.parse(id)] = god.deserializeFromMap(data, T);
return data;
}
Future<Object> remove(id, [Map params]) async {
var item = items[int.parse(id)];
items.remove(int.parse(id));
return item;
}
MemoryService() : super();
}

View file

@ -3,5 +3,10 @@ version: 0.0.1-dev
description: Core libraries for the Angel framework. description: Core libraries for the Angel framework.
author: Tobe O <thosakwe@gmail.com> author: Tobe O <thosakwe@gmail.com>
dependencies: dependencies:
body_parser: ^1.0.0-dev
json_god: any json_god: any
mime: ^0.9.3
route: any route: any
dev_dependencies:
http: any
test: any

72
test/routing.dart Normal file
View file

@ -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'));
});
});
}

54
test/services.dart Normal file
View file

@ -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<Todo>());
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!'));
});
});
});
}

32
test/util.dart Normal file
View file

@ -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'));
});
});
}