Angel.secure, fallback routes, 404, app.addRoute, app.all, services are a go (just missing params, i.e. $sort?), now have service.app, app.before, app.after, angel.configure now uses futures, errors are implemented

This commit is contained in:
regiostech 2016-04-28 20:01:58 -04:00
parent c2de78db0c
commit 93a29c43cf
10 changed files with 425 additions and 118 deletions

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# angel_framework
Documentation in the works.

96
lib/src/http/errors.dart Normal file
View file

@ -0,0 +1,96 @@
part of angel_framework.http;
class _AngelHttpExceptionBase implements Exception {
int statusCode;
String message;
List<String> errors;
_AngelHttpExceptionBase.base() {}
_AngelHttpExceptionBase(this.statusCode, this.message,
{List<String> 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<String> 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);
}

View file

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

View file

@ -13,15 +13,6 @@ class Routable extends Extensible {
/// 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);
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');
}
}

View file

@ -4,57 +4,97 @@ part of angel_framework.http;
typedef Future<HttpServer> 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("<DOCTYPE html><html><head><title>${e.message}</title>");
res.write("</head><body><h1>${e.message}</h1><ul>");
for (String error in e.errors) {
res.write("<li>$error</li>");
}
res.write("</ul></body></html>");
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<bool>.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() {}
/// 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);
};
}
}

View file

@ -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<List> index([Map params]) {
throw new MethodNotAllowedError('find');
throw new AngelHttpException.MethodNotAllowed();
}
/// Retrieves the desired resource.
Future<Object> read(id, [Map params]) {
throw new MethodNotAllowedError('get');
throw new AngelHttpException.MethodNotAllowed();
}
/// Creates a resource.
Future<Object> create(Map data, [Map params]) {
throw new MethodNotAllowedError('create');
throw new AngelHttpException.MethodNotAllowed();
}
/// Modifies a resource.
Future<Object> modify(id, Map data, [Map params]) {
throw new AngelHttpException.MethodNotAllowed();
}
/// Overwrites a resource.
Future<Object> update(id, Map data, [Map params]) {
throw new MethodNotAllowedError('update');
throw new AngelHttpException.MethodNotAllowed();
}
/// Removes the given resource.
Future<Object> 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);
}

View file

@ -5,26 +5,70 @@ class MemoryService<T> extends Service {
God god = new God();
Map <int, T> items = {};
Future<List> index([Map params]) async => items.values.toList();
Map makeJson(int index, T t) {
return mergeMap([god.serializeToMap(t), {'id': index}]);
}
Future<Object> read(id, [Map params]) async => items[int.parse(id)];
Future<List> index([Map params]) async {
return items.keys
.where((index) => items[index] != null)
.map((index) => makeJson(index, items[index]))
.toList();
}
Future<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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();

View file

@ -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 <thosakwe@gmail.com>
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"

View file

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

View file

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