diff --git a/.gitignore b/.gitignore index 7c280441..c216ecad 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,47 @@ doc/api/ # Don't commit pubspec lock file # (Library packages only! Remove pattern if developing an application package) pubspec.lock +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties diff --git a/.idea/runConfigurations/All_Tests__coverage_.xml b/.idea/runConfigurations/All_Tests__coverage_.xml new file mode 100644 index 00000000..ab71df17 --- /dev/null +++ b/.idea/runConfigurations/All_Tests__coverage_.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index 291e8ecf..b8dc9cf3 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,29 @@ # shelf -[![version 1.0.0](https://img.shields.io/badge/pub-v1.0.0-brightgreen.svg)](https://pub.dartlang.org/packages/angel_shelf) +[![Pub](https://img.shields.io/pub/v/angel_shelf.svg)](https://pub.dartlang.org/packages/angel_shelf) [![build status](https://travis-ci.org/angel-dart/shelf.svg)](https://travis-ci.org/angel-dart/shelf) -Shelf interop with Angel. Will be deprecated by v2.0.0. +Shelf interop with Angel. This package lets you run `package:shelf` handlers via a custom adapter. +It also includes a plug-in that configures Angel to *natively* run `shelf` response handlers. -By version 2 of Angel, I will migrate the server to run on top of `shelf`. -Until then, use the code in this repo to embed existing shelf apps into -your Angel applications. +Use the code in this repo to embed existing shelf apps into +your Angel applications. This way, you can migrate legacy applications without +having to rewrite your business logic. This will make it easy to layer your API over a production application, rather than having to port code. +* [Usage](#usage) + * [embedShelf](#embedshelf) + * [Communicating with Angel](#communicating-with-angel-with-embedshelf)) + * [supportShelf](#supportshelf) + * [Communicating with Angel](#communicating-with-angel-with-supportshelf) + +# Usage + +## embedShelf +This is a compliant `shelf` adapter that acts as an Angel request handler. You can use it as a middleware, +or attach it to individual routes. + ```dart import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; @@ -32,6 +45,71 @@ main() async { .addHandler(_echoRequest) )); + // Only on a specific route + app.get('/shelf', handler); + await app.startServer(InternetAddress.LOOPBACK_IP_V4, 3000); } ``` + +### Communicating with Angel with embedShelf +You can communicate with Angel: + +```dart +handleRequest(shelf.Request request) { + // Access original Angel request... + var req = request.context['angel_shelf.request'] as RequestContext; + + // ... And then interact with it. + req.inject('from_shelf', new Foo()); + + // `req.properties` are also available. + var props = request.context['angel_shelf.properties'] as Map; + +} +``` + +## supportShelf +This plug-in takes advantage of Angel's middleware system and dependency injection to patch a server +to run `shelf` request handlers as though they were Angel request handlers. Hooray for integration! + +You'll want to run this before adding any other response finalizers that depend on +the response content being effectively final, i.e. GZIP compression. + +**NOTE**: Do not inject a `shelf.Request` into your request under the name `req`. If you do, +Angel will automatically inject a `RequestContext` instead. + +```dart +configureServer(Angel app) async { + // Return a shelf Response + app.get('/shelf', (shelf.Request request) => new shelf.Response.ok('Yay!')); + + // Return an arbitrary value. + // + // This will be serialized by Angel as per usual. + app.get('/json', (shelf.Request request) => {'foo': 'bar'}); + + // You can still throw Angel exceptions. + // + // Don't be fooled: just because this is a shelf handler, doesn't mean + // it's not an Angel response handler too. ;) + app.get('/error', (shelf.Request request) { + throw new AngelHttpException.forbidden(); + }); + + // Make it all happen! + await app.configure(supportShelf()); +} +``` + +### Communicating with Angel with supportShelf +The following keys will be present in the shelf request's context: + * `angel_shelf.request` - Original RequestContext + * `angel_shelf.response` - Original ResponseContext + * `angel_shelf.properties` - Original RequestContext's properties + +If the original `RequestContext` contains a Map named `shelf_context` in its `properties`, +then it will be merged into the shelf request's context. + +If the handler returns a `shelf.Response`, then it will be present in `ResponseContext.properties` +as `shelf_response`. \ No newline at end of file diff --git a/lib/angel_shelf.dart b/lib/angel_shelf.dart index efa0d5a5..22e24582 100644 --- a/lib/angel_shelf.dart +++ b/lib/angel_shelf.dart @@ -1,13 +1,3 @@ -import 'package:angel_framework/angel_framework.dart'; -import 'package:shelf/shelf.dart' as shelf; -import 'package:shelf/shelf_io.dart' as io; - -/// Simply passes an incoming request to a `shelf` handler. -RequestHandler embedShelf(shelf.Handler handler) { - return (RequestContext req, ResponseContext res) async { - res - ..willCloseItself = true - ..end(); - io.handleRequest(req.io, handler); - }; -} +export 'src/convert.dart'; +export 'src/embed_shelf.dart'; +export 'src/support_shelf.dart'; \ No newline at end of file diff --git a/lib/src/convert.dart b/lib/src/convert.dart new file mode 100644 index 00000000..22a7ebf1 --- /dev/null +++ b/lib/src/convert.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:shelf/shelf.dart' as shelf; + +/// Creates a [shelf.Request] analogous to the input [request]. +/// +/// The new request's `context` will contain [request.properties] as `angel_shelf.properties`, as well as +/// the provided [context], if any. +/// +/// The context will also have the original request available as `angel_shelf.request`. +/// +/// If you want to read the request body, you *must* `storeOriginalBuffer` to `true` +/// on your application instance. +Future convertRequest(RequestContext request, + {String handlerPath, Map context}) async { + var headers = {}; + request.headers.forEach((k, v) { + headers[k] = v.join(','); + }); + + headers.remove(HttpHeaders.TRANSFER_ENCODING); + + void onHijack( + void hijack(Stream> stream, StreamSink> sink)) { + request.io.response.detachSocket(writeHeaders: false).then((socket) { + return request.lazyOriginalBuffer().then((buf) { + var ctrl = new StreamController>()..add(buf ?? []); + socket.listen(ctrl.add, onError: ctrl.addError, onDone: ctrl.close); + hijack(socket, socket); + }); + }).catchError((e, st) { + stderr.writeln('An error occurred while hijacking a shelf request: $e'); + stderr.writeln(st); + }); + } + + return new shelf.Request(request.method, request.io.requestedUri, + protocolVersion: request.io.protocolVersion, + headers: headers, + handlerPath: handlerPath, + url: new Uri( + path: request.io.requestedUri.path.substring(1), + query: request.io.requestedUri.query), + body: (await request.lazyOriginalBuffer()) ?? [], + context: {'angel_shelf.request': request} + ..addAll({'angel_shelf.properties': request.properties}) + ..addAll(context ?? {}), + onHijack: onHijack); +} + +/// Applies the state of the [shelfResponse] into the [angelResponse]. +/// +/// Merges all headers, sets the status code, and writes the body. +/// +/// In addition, the response's context will be available in `angelResponse.properties` +/// as `shelf_context`. +Future mergeShelfResponse( + shelf.Response shelfResponse, ResponseContext angelResponse) async { + angelResponse.headers.addAll(shelfResponse.headers); + angelResponse.statusCode = shelfResponse.statusCode; + angelResponse.properties['shelf_context'] = shelfResponse.context; + await shelfResponse.read().forEach(angelResponse.buffer.add); + angelResponse.end(); +} diff --git a/lib/src/embed_shelf.dart b/lib/src/embed_shelf.dart new file mode 100644 index 00000000..fc95e3a3 --- /dev/null +++ b/lib/src/embed_shelf.dart @@ -0,0 +1,36 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'convert.dart'; + +/// Simply passes an incoming request to a `shelf` handler. +/// +/// If the handler does not return a [shelf.Response], then the +/// result will be passed down the Angel middleware pipeline, like with +/// any other request handler. +/// +/// If [throwOnNullResponse] is `true` (default: `false`), then a 500 error will be thrown +/// if the [handler] returns `null`. +RequestHandler embedShelf(shelf.Handler handler, + {String handlerPath, + Map context, + bool throwOnNullResponse: false}) { + return (RequestContext req, ResponseContext res) async { + var shelfRequest = + await convertRequest(req, handlerPath: handlerPath, context: context); + try { + var result = await handler(shelfRequest); + if (result is! shelf.Response && result != null) return result; + if (result == null && throwOnNullResponse == true) + throw new AngelHttpException('Internal Server Error'); + await mergeShelfResponse(result, res); + return false; + } on shelf.HijackException { + // On hijack... + res + ..willCloseItself = true + ..end(); + } catch (e) { + rethrow; + } + }; +} diff --git a/lib/src/support_shelf.dart b/lib/src/support_shelf.dart new file mode 100644 index 00000000..ab6b9a33 --- /dev/null +++ b/lib/src/support_shelf.dart @@ -0,0 +1,40 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'convert.dart'; + +/// Configures a server instance to natively run shelf request handlers. +/// +/// To pass a context to the generated shelf request, add a Map +/// to `req.properties`, named `shelf_context`. +/// +/// Two additional keys will be present in the `shelf` request context: +/// * `angel_shelf.request` - The Angel [RequestContext]. +/// * `angel_shelf.response` - The Angel [ResponseContext]. +AngelConfigurer supportShelf() { + return (Angel app) async { + app.before.add((RequestContext req, ResponseContext res) async { + // Inject shelf.Request ;) + req.inject( + shelf.Request, + await convertRequest(req, + context: {'angel_shelf.response': res} + ..addAll(req.properties['shelf_context'] ?? {}))); + + // Override serializer to support returning shelf responses + var oldSerializer = res.serializer; + res.serializer = (val) { + if (val is! shelf.Response) return oldSerializer(val); + res.properties['shelf_response'] = val; + return ''; // Write nothing + }; + }); + + // Merge shelf response if necessary + app.responseFinalizers.add((RequestContext req, ResponseContext res) async { + if (res.properties.containsKey('shelf_response')) { + var shelfResponse = res.properties['shelf_response'] as shelf.Response; + await mergeShelfResponse(shelfResponse, res); + } + }); + }; +} diff --git a/pubspec.yaml b/pubspec.yaml index 0f018d5b..8b5ea860 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: angel_shelf -description: Shelf interop with Angel. Will be deprecated by v2.0.0. -version: 1.0.0 +description: Shelf interop with Angel. +version: 1.1.0 author: Tobe O homepage: https://github.com/angel-dart/shelf environment: @@ -9,5 +9,6 @@ dependencies: angel_framework: ^1.0.0-dev shelf: ^0.6.0 dev_dependencies: - angel_test: ^1.0.0-dev + angel_diagnostics: ^1.0.0 + angel_test: ^1.0.0 test: ^0.12.0 \ No newline at end of file diff --git a/test/all.dart b/test/all.dart new file mode 100644 index 00000000..79cee043 --- /dev/null +++ b/test/all.dart @@ -0,0 +1,8 @@ +import 'package:test/test.dart'; +import 'embed_shelf_test.dart' as embed_shelf; +import 'support_shelf_test.dart' as support_shelf; + +main() { + group('embed_shelf', embed_shelf.main); + group('support_shelf', support_shelf.main); +} \ No newline at end of file diff --git a/test/all_test.dart b/test/all_test.dart deleted file mode 100644 index d03ce8cc..00000000 --- a/test/all_test.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:convert'; -import 'package:angel_framework/angel_framework.dart'; -import 'package:angel_shelf/angel_shelf.dart'; -import 'package:angel_test/angel_test.dart'; -import 'package:shelf/shelf.dart' as shelf; -import 'package:shelf/shelf_io.dart' as io; -import 'package:test/test.dart'; - -main() async { - Angel app; - TestClient client; - - setUp(() async { - var handler = new shelf.Pipeline() - .addMiddleware(shelf.logRequests()) - .addHandler(_echoRequest); - - app = new Angel(); - app.get('/angel', 'Angel'); - app.after.add(embedShelf(handler)); - - client = await connectTo(app); - }); - - tearDown(() => client.close()); - - test('expose angel side', () async { - var response = await client.get('/angel'); - expect(JSON.decode(response.body), equals('Angel')); - }); - - test('expose shelf side', () async { - var response = await client.get('/foo'); - expect(response, hasStatus(200)); - expect(response.body, equals('Request for "foo"')); - }); -} - -shelf.Response _echoRequest(shelf.Request request) { - return new shelf.Response.ok('Request for "${request.url}"'); -} diff --git a/test/embed_shelf_test.dart b/test/embed_shelf_test.dart new file mode 100644 index 00000000..775978ee --- /dev/null +++ b/test/embed_shelf_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_client/io.dart' as c; +import 'package:angel_diagnostics/angel_diagnostics.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_shelf/angel_shelf.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:charcode/charcode.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:test/test.dart'; + +main() { + c.Angel client; + HttpServer server; + String url; + + setUp(() async { + var handler = new shelf.Pipeline().addHandler((shelf.Request request) { + if (request.url.path == 'two') + return 2; + else if (request.url.path == 'error') + throw new AngelHttpException.notFound(); + else if (request.url.path == 'status') + return new shelf.Response.notModified(headers: {'foo': 'bar'}); + else if (request.url.path == 'hijack') { + request.hijack((Stream> stream, StreamSink> sink) { + sink.add(UTF8.encode('HTTP/1.1 200 OK\r\n')); + sink.add([$lf]); + sink.add(UTF8.encode(JSON.encode({'error': 'crime'}))); + sink.close(); + }); + } else if (request.url.path == 'throw') + return null; + else + return new shelf.Response.ok('Request for "${request.url}"'); + }); + + var app = new Angel()..lazyParseBodies = true; + app.get('/angel', 'Angel'); + app.after.add(embedShelf(handler, throwOnNullResponse: true)); + await app.configure(logRequests()); + + server = await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); + client = + new c.Rest(url = 'http://${server.address.address}:${server.port}'); + }); + + tearDown(() async { + await client.close(); + await server.close(force: true); + }); + + test('expose angel side', () async { + var response = await client.get('/angel'); + expect(JSON.decode(response.body), equals('Angel')); + }); + + test('expose shelf side', () async { + var response = await client.get('/foo'); + expect(response, hasStatus(200)); + expect(response.body, equals('Request for "foo"')); + }); + + test('shelf can return arbitrary values', () async { + var response = await client.get('/two'); + expect(response, isJson(2)); + }); + + test('shelf can hijack', () async { + try { + var client = new HttpClient(); + var rq = await client.openUrl('GET', Uri.parse('$url/hijack')); + var rs = await rq.close(); + var body = await rs.transform(UTF8.decoder).join(); + print('Response: $body'); + expect(JSON.decode(body), {'error': 'crime'}); + } on HttpException catch (e, st) { + print('HTTP Exception: ' + e.message); + print(st); + rethrow; + } + }); + + test('shelf can set status code', () async { + var response = await client.get('/status'); + expect(response, allOf(hasStatus(304), hasHeader('foo', 'bar'))); + }); + + test('shelf can throw error', () async { + var response = await client.get('/error'); + expect(response, hasStatus(404)); + }); + + test('throw on null', () async { + var response = await client.get('/throw'); + expect(response, hasStatus(500)); + }); +} diff --git a/test/support_shelf_test.dart b/test/support_shelf_test.dart new file mode 100644 index 00000000..5f641b7a --- /dev/null +++ b/test/support_shelf_test.dart @@ -0,0 +1,41 @@ +import 'package:angel_diagnostics/angel_diagnostics.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_shelf/angel_shelf.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:test/test.dart'; + +main() { + TestClient client; + + setUp(() async { + var app = new Angel()..lazyParseBodies = true; + + app.get('/inject', (shelf.Request request) { + print('URL of injected request: ${request.url.path}'); + return {'inject': request.url.path == 'inject'}; + }); + + app.get('/hello', (shelf.Request request) { + return new shelf.Response.ok('world'); + }); + + await app.configure(supportShelf()); + await app.configure(logRequests()); + client = await connectTo(app); + }); + + tearDown(() => client.close()); + + test('injected into request', () async { + var response = await client.get('/inject'); + print('Response: ${response.body}'); + expect(response, isJson({'inject': true})); + }); + + test('can return shelf response', () async { + var response = await client.get('/hello'); + print('Response: ${response.body}'); + expect(response, hasBody('world')); + }); +}