From 4d7cbb679e6ce361f47519773a70d58bcbb072d2 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sun, 24 Sep 2017 14:49:18 -0400 Subject: [PATCH] 1.1.0-alpha --- .idea/angel_proxy.iml | 3 - .idea/runConfigurations/All_Tests.xml | 1 + .idea/runConfigurations/multiple_dart.xml | 7 + README.md | 30 +-- .analysis-options => analysis_options.yaml | 0 example/multiple.dart | 45 +++-- lib/angel_proxy.dart | 1 - lib/src/proxy_layer.dart | 212 ++++++++------------- lib/src/pub_serve_layer.dart | 37 ---- pubspec.yaml | 12 +- test/basic_test.dart | 55 ++++-- test/common.dart | 14 +- test/pub_serve_test.dart | 59 +++--- 13 files changed, 216 insertions(+), 260 deletions(-) create mode 100644 .idea/runConfigurations/multiple_dart.xml rename .analysis-options => analysis_options.yaml (100%) delete mode 100644 lib/src/pub_serve_layer.dart diff --git a/.idea/angel_proxy.iml b/.idea/angel_proxy.iml index f5c40a17..eae13016 100644 --- a/.idea/angel_proxy.iml +++ b/.idea/angel_proxy.iml @@ -5,10 +5,7 @@ - - - diff --git a/.idea/runConfigurations/All_Tests.xml b/.idea/runConfigurations/All_Tests.xml index 107ff326..44742797 100644 --- a/.idea/runConfigurations/All_Tests.xml +++ b/.idea/runConfigurations/All_Tests.xml @@ -2,6 +2,7 @@ \ No newline at end of file diff --git a/.idea/runConfigurations/multiple_dart.xml b/.idea/runConfigurations/multiple_dart.xml new file mode 100644 index 00000000..36b7b1d6 --- /dev/null +++ b/.idea/runConfigurations/multiple_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index bdd1658a..fd31dbaf 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,33 @@ -# angel_proxy +# proxy +[![Pub](https://img.shields.io/pub/v/angel_proxy.svg)](https://pub.dartlang.org/packages/angel_proxy) +[![build status](https://travis-ci.org/angel-dart/proxy.svg)](https://travis-ci.org/angel-dart/proxy) -[![Pub](https://img.shields.io/pub/v/angel_proxy.svg)](https://pub.dartlang.org/packages/angel_proxy)[![build status](https://travis-ci.org/angel-dart/proxy.svg)](https://travis-ci.org/angel-dart/proxy) - -Angel middleware to forward requests to another server (i.e. pub serve). +Angel middleware to forward requests to another server (i.e. `pub serve`). ```dart import 'package:angel_proxy/angel_proxy.dart'; +import 'package:http/http.dart' as http; main() async { // ... - // Forward requests instead of serving statically - await app.configure(new ProxyLayer('localhost', 3000)); + var client = new http.Client(); + var proxy = new Proxy(app, client, 'http://localhost:3000'); - // Or, use one for pub serve. - // - // This automatically deactivates itself if the app is - // in production. - await app.configure(new PubServeLayer()); + // Forward requests instead of serving statically + app.use(proxy.handleRequest); } ``` +You can also restrict the proxy to serving only from a specific root: +```dart +new Proxy(app, client, '', publicPath: '/remote'); +``` + +Also, you can map requests to a root path on the remote server +```dart +new Proxy(app, client, '', mapTo: '/path'); +``` + If your app's `storeOriginalBuffer` is `true`, then request bodies will be forwarded as well, if they are not empty. This allows things like POST requests to function. diff --git a/.analysis-options b/analysis_options.yaml similarity index 100% rename from .analysis-options rename to analysis_options.yaml diff --git a/example/multiple.dart b/example/multiple.dart index 0bce8c21..aac06254 100644 --- a/example/multiple.dart +++ b/example/multiple.dart @@ -1,38 +1,55 @@ import 'dart:io'; -import 'package:angel_diagnostics/angel_diagnostics.dart'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_proxy/angel_proxy.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; -final Duration TIMEOUT = new Duration(seconds: 5); +final Duration timeout = new Duration(seconds: 5); main() async { var app = new Angel(); + var client = new http.Client(); // Forward any /api requests to pub. // By default, if the host throws a 404, the request will fall through to the next handler. - var pubProxy = new ProxyLayer('pub.dartlang.org', 80, - publicPath: '/pub', timeout: TIMEOUT); - await app.configure(pubProxy); + var pubProxy = new Proxy( + app, + client, + 'https://pub.dartlang.org', + publicPath: '/pub', + timeout: timeout, + ); + app.use(pubProxy.handleRequest); // Pub's HTML assumes that the site's styles, etc. are on the absolute path `/static`. // This is not the case here. Let's patch that up: app.get('/static/*', (RequestContext req, res) { - return pubProxy.serveFile(req.path, req, res); + return pubProxy.servePath(req.path, req, res); }); // Anything else should fall through to dartlang.org. - await app.configure(new ProxyLayer('dartlang.org', 80, timeout: TIMEOUT)); + var dartlangProxy = new Proxy( + app, + client, + 'https://dartlang.org', + timeout: timeout, + recoverFrom404: false, + ); + app.use(dartlangProxy.handleRequest); // In case we can't connect to dartlang.org, show an error. - app.after.add('Couldn\'t connect to Pub or dartlang.'); + app.use('Couldn\'t connect to Pub or dartlang.'); - await app.configure(logRequests()); - - app.fatalErrorStream.listen((AngelFatalError e) { - print(e.error); - print(e.stack); - }); + app.logger = new Logger('angel') + ..onRecord.listen( + (rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }, + ); var server = await app.startServer(InternetAddress.LOOPBACK_IP_V4, 8080); print('Listening at http://${server.address.address}:${server.port}'); + print('Check this out! http://${server.address.address}:${server.port}/pub/packages/angel_framework'); } diff --git a/lib/angel_proxy.dart b/lib/angel_proxy.dart index f2f6b503..a0138f3e 100644 --- a/lib/angel_proxy.dart +++ b/lib/angel_proxy.dart @@ -1,4 +1,3 @@ library angel_proxy; export 'src/proxy_layer.dart'; -export 'src/pub_serve_layer.dart'; diff --git a/lib/src/proxy_layer.dart b/lib/src/proxy_layer.dart index 6034f3cc..a117f47c 100644 --- a/lib/src/proxy_layer.dart +++ b/lib/src/proxy_layer.dart @@ -1,142 +1,107 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; +import 'package:http/src/base_client.dart' as http; +import 'package:http/src/request.dart' as http; +import 'package:http/src/response.dart' as http; +import 'package:http/src/streamed_response.dart' as http; final RegExp _param = new RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?'); final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); -/// Used to mount a route for a [ProxyLayer]. -typedef Route ProxyLayerRouteAssigner(Router router, String path, handler); - -String _pathify(String path) { - var p = path.replaceAll(_straySlashes, ''); - - Map replace = {}; - - for (Match match in _param.allMatches(p)) { - if (match[3] != null) replace[match[0]] = ':${match[1]}'; - } - - replace.forEach((k, v) { - p = p.replaceAll(k, v); - }); - - return p; -} - -/// Copies HTTP headers ;) -void copyHeaders(HttpHeaders from, HttpHeaders to) { - from.forEach((k, v) { - if (k != HttpHeaders.SERVER && - (k != HttpHeaders.CONTENT_ENCODING || !v.contains('gzip'))) - to.set(k, v); - }); - - /*to - ..chunkedTransferEncoding = from.chunkedTransferEncoding - ..contentLength = from.contentLength - ..contentType = from.contentType - ..date = from.date - ..expires = from.expires - ..host = from.host - ..ifModifiedSince = from.ifModifiedSince - ..persistentConnection = from.persistentConnection - ..port = from.port;*/ -} - -class ProxyLayer { - Angel app; - HttpClient _client; +class Proxy { String _prefix; + final Angel app; + final http.BaseClient httpClient; + /// If `true` (default), then the plug-in will ignore failures to connect to the proxy, and allow other handlers to run. final bool recoverFromDead; - final bool debug, recoverFrom404, streamToIO; + final bool recoverFrom404; final String host, mapTo, publicPath; final int port; final String protocol; final Duration timeout; - ProxyLayerRouteAssigner routeAssigner; - ProxyLayer( - this.host, - this.port, { - this.debug: false, + Proxy( + this.app, + this.httpClient, + this.host, { + this.port, this.mapTo: '/', this.publicPath: '/', this.protocol: 'http', this.recoverFromDead: true, this.recoverFrom404: true, - this.streamToIO: false, this.timeout, - this.routeAssigner, - SecurityContext securityContext, }) { - _client = new HttpClient(context: securityContext); _prefix = publicPath.replaceAll(_straySlashes, ''); - routeAssigner ??= - (Router router, String path, handler) => router.get(path, handler); } - call(Angel app) async => serve(this.app = app); + void close() => httpClient.close(); - void close() => _client.close(force: true); + /// Handles an incoming HTTP request. + Future handleRequest(RequestContext req, ResponseContext res) { + var path = req.path.replaceAll(_straySlashes, ''); - void serve(Router router) { - // _printDebug('Public path prefix: "$_prefix"'); - - handler(RequestContext req, ResponseContext res) async { - var path = req.path.replaceAll(_straySlashes, ''); - return serveFile(path, req, res); + if (_prefix?.isNotEmpty == true) { + if (!path.startsWith(_prefix)) + return new Future.value(true); + else { + path = path.replaceFirst(_prefix, '').replaceAll(_straySlashes, ''); + } } - routeAssigner(router, '$publicPath/*', handler); + return servePath(path, req, res); } - serveFile(String path, RequestContext req, ResponseContext res) async { - var _path = path; - HttpClientResponse rs; + /// Proxies a request to the given path on the remote server. + Future servePath( + String path, RequestContext req, ResponseContext res) async { + http.StreamedResponse rs; - if (_prefix.isNotEmpty) { - _path = path.replaceAll(new RegExp('^' + _pathify(_prefix)), ''); - } - - // Create mapping - // _printDebug('Serving path $_path via proxy'); - final mapping = '$mapTo/$_path'.replaceAll(_straySlashes, ''); - // _printDebug('Mapped path $_path to path $mapping on proxy $host:$port'); + final mapping = '$mapTo/$path'.replaceAll(_straySlashes, ''); try { - Future accessRemote() async { - var ips = await InternetAddress.lookup(host); - if (ips.isEmpty) - throw new StateError('Could not resolve remote host "$host".'); - var address = ips.first.address; - final rq = await _client.open(req.method, address, port, mapping); - // _printDebug('Opened client request at "$address:$port/$mapping"'); + Future accessRemote() async { + var url = port == null ? host : '$host:$port'; + url = url.replaceAll(_straySlashes, ''); + url = '$url/$mapping'; - copyHeaders(req.headers, rq.headers); - rq.headers.set(HttpHeaders.HOST, host); - // _printDebug('Copied headers'); - rq.cookies.addAll(req.cookies ?? []); - // _printDebug('Added cookies'); - rq.headers.set( - 'X-Forwarded-For', req.io.connectionInfo.remoteAddress.address); - rq.headers - ..set('X-Forwarded-Port', req.io.connectionInfo.remotePort.toString()) - ..set('X-Forwarded-Host', - req.headers.host ?? req.headers.value(HttpHeaders.HOST) ?? 'none') - ..set('X-Forwarded-Proto', protocol); - // _printDebug('Added X-Forwarded headers'); + if (!url.startsWith('http')) url = 'http://$url'; + url = url.replaceAll(_straySlashes, ''); - if (app.storeOriginalBuffer == true) { + var headers = { + 'host': port == null ? host : '$host:$port', + 'x-forwarded-for': req.io.connectionInfo.remoteAddress.address, + 'x-forwarded-port': req.io.connectionInfo.remotePort.toString(), + 'x-forwarded-host': + req.headers.host ?? req.headers.value('host') ?? 'none', + 'x-forwarded-proto': protocol, + }; + + req.headers.forEach((name, values) { + headers[name] = values.join(','); + }); + + headers['cookie'] = + req.cookies.map((c) => '${c.name}=${c.value}').join('; '); + + var body; + + if (req.method != 'GET' && app.storeOriginalBuffer == true) { await req.parse(); - if (req.originalBuffer?.isNotEmpty == true) - rq.add(req.originalBuffer); + if (req.originalBuffer?.isNotEmpty == true) body = req.originalBuffer; } - await rq.flush(); - return await rq.close(); + var rq = new http.Request(req.method, Uri.parse(url)); + rq.headers.addAll(headers); + rq.headers['host'] = rq.url.host; + + if (body != null) rq.bodyBytes = body; + + return await httpClient.send(rq); } var future = accessRemote(); @@ -146,11 +111,13 @@ class ProxyLayer { if (recoverFromDead != false) return true; else - throw new AngelHttpException(e, - stackTrace: st, - statusCode: HttpStatus.GATEWAY_TIMEOUT, - message: - 'Connection to remote host "$host" timed out after ${timeout.inMilliseconds}ms.'); + throw new AngelHttpException( + e, + stackTrace: st, + statusCode: 504, + message: + 'Connection to remote host "$host" timed out after ${timeout.inMilliseconds}ms.', + ); } catch (e) { if (recoverFromDead != false) return true; @@ -158,44 +125,21 @@ class ProxyLayer { rethrow; } - // _printDebug( - // 'Proxy responded to $mapping with status code ${rs.statusCode}'); - if (rs.statusCode == 404 && recoverFrom404 != false) return true; res ..statusCode = rs.statusCode - ..contentType = rs.headers.contentType; + ..headers.addAll(rs.headers); - // _printDebug('Proxy response headers:\n${rs.headers}'); + if (rs.contentLength == 0 && recoverFromDead != false) return true; - if (streamToIO == true) { - res - ..willCloseItself = true - ..end(); + var stream = rs.stream; - copyHeaders(rs.headers, res.io.headers); - res.io.statusCode = rs.statusCode; + if (rs.headers['content-encoding'] == 'gzip') + stream = stream.transform(GZIP.encoder); - if (rs.headers.contentType != null) - res.io.headers.contentType = rs.headers.contentType; + await stream.pipe(res); - // _printDebug('Outgoing content length: ${res.io.contentLength}'); - - if (rs.headers[HttpHeaders.CONTENT_ENCODING]?.contains('gzip') == true) { - res.io.headers.set(HttpHeaders.CONTENT_ENCODING, 'gzip'); - await rs.transform(GZIP.encoder).pipe(res.io); - } else - await rs.pipe(res.io); - } else { - rs.headers.forEach((k, v) { - if (k != HttpHeaders.CONTENT_ENCODING || !v.contains('gzip')) - res.headers[k] = v.join(','); - }); - - await rs.forEach(res.buffer.add); - } - - return res.buffer.isEmpty; + return false; } } diff --git a/lib/src/pub_serve_layer.dart b/lib/src/pub_serve_layer.dart deleted file mode 100644 index 59c59615..00000000 --- a/lib/src/pub_serve_layer.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:angel_route/angel_route.dart'; -import 'proxy_layer.dart'; - -class PubServeLayer extends ProxyLayer { - PubServeLayer( - {bool debug: false, - bool recoverFromDead: true, - bool recoverFrom404: true, - bool streamToIO: true, - String host: 'localhost', - String mapTo: '/', - int port: 8080, - String protocol: 'http', - String publicPath: '/', - ProxyLayerRouteAssigner routeAssigner, - Duration timeout}) - : super(host, port, - debug: debug, - mapTo: mapTo, - protocol: protocol, - publicPath: publicPath, - recoverFromDead: recoverFromDead != false, - recoverFrom404: recoverFrom404 != false, - streamToIO: streamToIO != false, - routeAssigner: routeAssigner, - timeout: timeout); - - @override - void serve(Router router) { - if (app?.isProduction == true) { - // Auto-deactivate in production ;) - return; - } - - super.serve(router); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 4f1a5383..d73e9c32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,16 +1,14 @@ name: angel_proxy description: Angel middleware to forward requests to another server (i.e. pub serve). -version: 1.0.9 +version: 1.1.0-alpha author: Tobe O homepage: https://github.com/angel-dart/proxy environment: sdk: ">=1.19.0" dependencies: - angel_framework: ^1.0.0-dev + angel_framework: ^1.1.0-alpha + http: ^0.11.3 dev_dependencies: - angel_compress: ^1.0.0 - angel_diagnostics: ^1.0.0 - angel_test: ^1.0.0 - http: ^0.11.3 - test: ^0.12.15 + angel_test: ^1.1.0-alpha + test: ^0.12.15 \ No newline at end of file diff --git a/test/basic_test.dart b/test/basic_test.dart index d47eca3f..47144e4e 100644 --- a/test/basic_test.dart +++ b/test/basic_test.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_proxy/angel_proxy.dart'; -import 'package:angel_test/angel_test.dart'; import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; import 'package:test/test.dart'; import 'common.dart'; @@ -15,18 +15,44 @@ main() { setUp(() async { app = new Angel()..storeOriginalBuffer = true; + var httpClient = new http.Client(); testServer = await testApp().startServer(); - await app.configure(new ProxyLayer( - testServer.address.address, testServer.port, - publicPath: '/proxy', - routeAssigner: (router, path, handler) => router.all(path, handler))); - await app.configure(new ProxyLayer( - testServer.address.address, testServer.port, - mapTo: '/foo')); + var proxy1 = new Proxy( + app, + httpClient, + testServer.address.address, + port: testServer.port, + publicPath: '/proxy', + ); + var proxy2 = new Proxy( + app, + httpClient, + testServer.address.address, + port: testServer.port, + mapTo: '/foo', + ); - app.after.add((req, res) async => res.write('intercept empty')); + app.use(proxy1.handleRequest); + app.use(proxy2.handleRequest); + + app.use((req, res) { + print('Intercepting empty from ${req.uri}'); + res.write('intercept empty'); + }); + + app.shutdownHooks.add((_) async { + httpClient.close(); + }); + + app.logger = new Logger('angel'); + + Logger.root.onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); server = await app.startServer(); url = 'http://${server.address.address}:${server.port}'; @@ -48,11 +74,6 @@ main() { test('empty', () async { var response = await client.get('$url/proxy/empty'); print('Response: ${response.body}'); - - // Shouldn't say it is gzipped... - expect(response, isNot(hasHeader('content-encoding'))); - - // Should have gzipped body expect(response.body, 'intercept empty'); }); @@ -64,10 +85,10 @@ main() { test('original buffer', () async { var response = await client.post('$url/proxy/body', - body: {'foo': 'bar'}, - headers: {'content-type': 'application/x-www-form-urlencoded'}); + body: JSON.encode({'foo': 'bar'}), + headers: {'content-type': 'application/json'}); print('Response: ${response.body}'); expect(response.body, isNotEmpty); - expect(JSON.decode(response.body), {'foo': 'bar'}); + expect(response.body, isNot('intercept empty')); }); } diff --git a/test/common.dart b/test/common.dart index 485a87ff..bfa3c1bc 100644 --- a/test/common.dart +++ b/test/common.dart @@ -1,16 +1,18 @@ import 'package:angel_framework/angel_framework.dart'; +import 'package:logging/logging.dart'; Angel testApp() { - final app = new Angel(); + final app = new Angel()..lazyParseBodies = true; app.get('/hello', 'world'); app.get('/foo/bar', 'baz'); - app.post('/body', (req, res) => req.lazyBody()); - - app.fatalErrorStream.listen((e) { - print('FATAL IN TEST APP: ${e.error}'); - print(e.stack); + app.post('/body', (RequestContext req, res) async { + var body = await req.lazyBody(); + print('Body: $body'); + return body; }); + app.logger = new Logger('testApp'); + return app..dumpTree(); } diff --git a/test/pub_serve_test.dart b/test/pub_serve_test.dart index 5158cdae..e1466476 100644 --- a/test/pub_serve_test.dart +++ b/test/pub_serve_test.dart @@ -1,9 +1,10 @@ import 'dart:convert'; import 'dart:io'; -import 'package:angel_compress/angel_compress.dart'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_proxy/angel_proxy.dart'; import 'package:angel_test/angel_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mock_request/mock_request.dart'; import 'package:test/test.dart'; main() { @@ -12,27 +13,40 @@ main() { setUp(() async { testApp = new Angel(); - testApp.get('/foo', (req, res) => res.write('pub serve')); + testApp.get('/foo', (req, res) async { + res.write('pub serve'); + }); testApp.get('/empty', (req, res) => res.end()); - testApp.responseFinalizers.add(gzip()); + + testApp.responseFinalizers.add((req, ResponseContext res) async { + print('OUTGOING: ' + new String.fromCharCodes(res.buffer.toBytes())); + }); + + testApp.injectEncoders({'gzip': GZIP.encoder}); + var server = await testApp.startServer(); app = new Angel(); app.get('/bar', (req, res) => res.write('normal')); - var layer = new PubServeLayer( - debug: true, - publicPath: '/proxy', - host: server.address.address, - port: server.port); - print('streamToIO: ${layer.streamToIO}'); - await app.configure(layer); + + var httpClient = new http.Client(); + + var layer = new Proxy( + app, + httpClient, + server.address.address, + port: server.port, + publicPath: '/proxy', + ); + app.use(layer.handleRequest); app.responseFinalizers.add((req, ResponseContext res) async { print('Normal. Buf: ' + new String.fromCharCodes(res.buffer.toBytes()) + ', headers: ${res.headers}'); }); - app.responseFinalizers.add(gzip()); + + app.injectEncoders({'gzip': GZIP.encoder}); client = await connectTo(app); }); @@ -46,34 +60,19 @@ main() { }); test('proxied', () async { - var response = await client.get('/proxy/foo'); - - // Should say it is gzipped... - expect(response, hasHeader('content-encoding', 'gzip')); - - // Should have gzipped body - // - // We have to decode it, because `mock_request` does not auto-decode. - expect(response, hasBody('pub serve')); + var rq = new MockHttpRequest('GET', Uri.parse('/proxy/foo'))..close(); + await app.handleRequest(rq); + var response = await rq.response.transform(UTF8.decoder).join(); + expect(response, 'pub serve'); }); test('empty', () async { var response = await client.get('/proxy/empty'); - - // Should say it is gzipped... - expect(response, hasHeader('content-encoding', 'gzip')); - - // Should have gzipped body expect(response.body, isEmpty); }); test('normal', () async { var response = await client.get('/bar'); - - // Should say it is gzipped... - expect(response, hasHeader('content-encoding', 'gzip')); - - // Should have normal body expect(response, hasBody('normal')); }); }