diff --git a/packages/shelf/.gitignore b/packages/shelf/.gitignore new file mode 100644 index 00000000..8fff3906 --- /dev/null +++ b/packages/shelf/.gitignore @@ -0,0 +1,72 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.buildlog +.packages +.project +.pub/ +build/ +**/packages/ + +# Files created by dart2js +# (Most Dart developers will use pub build to compile Dart, use/modify these +# rules if you intend to use dart2js directly +# Convention is to use extension '.dart.js' for Dart compiled to Javascript to +# differentiate from explicit Javascript files) +*.dart.js +*.part.js +*.js.deps +*.js.map +*.info.json + +# Directory created by dartdoc +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 +.dart_tool \ No newline at end of file diff --git a/packages/shelf/.idea/modules.xml b/packages/shelf/.idea/modules.xml new file mode 100644 index 00000000..c39dfcd7 --- /dev/null +++ b/packages/shelf/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/shelf/.idea/runConfigurations/All_Tests__coverage_.xml b/packages/shelf/.idea/runConfigurations/All_Tests__coverage_.xml new file mode 100644 index 00000000..ab71df17 --- /dev/null +++ b/packages/shelf/.idea/runConfigurations/All_Tests__coverage_.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/packages/shelf/.idea/runConfigurations/tests_in_shelf.xml b/packages/shelf/.idea/runConfigurations/tests_in_shelf.xml new file mode 100644 index 00000000..a6105b27 --- /dev/null +++ b/packages/shelf/.idea/runConfigurations/tests_in_shelf.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/packages/shelf/.idea/shelf.iml b/packages/shelf/.idea/shelf.iml new file mode 100644 index 00000000..eae13016 --- /dev/null +++ b/packages/shelf/.idea/shelf.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/shelf/.idea/vcs.xml b/packages/shelf/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/packages/shelf/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/shelf/.travis.yml b/packages/shelf/.travis.yml new file mode 100644 index 00000000..de2210c9 --- /dev/null +++ b/packages/shelf/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file diff --git a/packages/shelf/CHANGELOG.md b/packages/shelf/CHANGELOG.md new file mode 100644 index 00000000..70ea8a9e --- /dev/null +++ b/packages/shelf/CHANGELOG.md @@ -0,0 +1,9 @@ +# 2.1.0 +* `pedantic` lints. +* Add the `AngelShelf` driver class, which allows you to embed Angel within shelf. + +# 2.0.0 +* Removed `supportShelf`. + +# 1.2.0 +* Upgraded for `>=1.1.0` compatibility. \ No newline at end of file diff --git a/packages/shelf/LICENSE b/packages/shelf/LICENSE new file mode 100644 index 00000000..15fe44bd --- /dev/null +++ b/packages/shelf/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 The Angel Framework + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/shelf/README.md b/packages/shelf/README.md new file mode 100644 index 00000000..5882530a --- /dev/null +++ b/packages/shelf/README.md @@ -0,0 +1,105 @@ +# 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. This package lets you run `package:shelf` handlers via a custom adapter. + +Use the code in this repo to embed existing Angel/shelf apps into +other Angel/shelf 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) + - [`AngelShelf`](#angelshelf) + +# 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'; +import 'package:angel_shelf/angel_shelf.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'api/api.dart'; + +main() async { + var app = Angel(); + var http = AngelHttp(app); + + // Angel routes on top + await app.mountController(); + + // Re-route all other traffic to an + // existing application. + app.fallback(embedShelf( + shelf.Pipeline() + .addMiddleware(shelf.logRequests()) + .addHandler(_echoRequest) + )); + + // Or, only on a specific route: + app.get('/shelf', wrappedShelfHandler); + + await http.startServer(InternetAddress.loopbackIPV4, 3000); + print(http.uri); +} +``` + +### 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.container.registerNamedSingleton('from_shelf', Foo()); + + // `req.container` is also available. + var container = request.context['angel_shelf.container'] as Container; + container.make().drive(); +} +``` + +### AngelShelf +Angel 2 brought about the generic `Driver` class, which is implemented +by `AngelHttp`, `AngelHttp2`, `AngelGopher`, etc., and provides the core +infrastructure for request handling in Angel. + +`AngelShelf` is an implementation that wraps shelf requests and responses in their +Angel equivalents. Using it is as simple using as using `AngelHttp`, or any other +driver: + +```dart +// Create an AngelShelf driver. +// If we have startup hooks we want to run, we need to call +// `startServer`. Otherwise, it can be omitted. +// Of course, if you call `startServer`, know that to run +// shutdown/cleanup logic, you need to call `close` eventually, +// too. +var angelShelf = AngelShelf(app); +await angelShelf.startServer(); + +await shelf_io.serve(angelShelf.handler, InternetAddress.loopbackIPv4, 8081); +``` + +You can also use the `AngelShelf` driver as a shelf middleware - just use +`angelShelf.middleware` instead of `angelShelf.handler`. When used as a middleware, +if the Angel response context is still open after all handlers run (i.e. no routes were +matched), the next shelf handler will be called. + +```dart +var handler = shelf.Pipeline() + .addMiddleware(angelShelf.middleware) + .addHandler(createStaticHandler(...)); +``` \ No newline at end of file diff --git a/packages/shelf/analysis_options.yaml b/packages/shelf/analysis_options.yaml new file mode 100644 index 00000000..a4f33350 --- /dev/null +++ b/packages/shelf/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:pedantic/analysis_options.yaml +analyzer: + strong-mode: + implicit-casts: false diff --git a/packages/shelf/example/angel_in_shelf.dart b/packages/shelf/example/angel_in_shelf.dart new file mode 100644 index 00000000..808bccf2 --- /dev/null +++ b/packages/shelf/example/angel_in_shelf.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_shelf/angel_shelf.dart'; +import 'package:logging/logging.dart'; +import 'package:pretty_logging/pretty_logging.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_static/shelf_static.dart'; + +main() async { + Logger.root + ..level = Level.ALL + ..onRecord.listen(prettyLog); + + // Create a basic Angel server, with some routes. + var app = Angel( + logger: Logger('angel_shelf_demo'), + reflector: MirrorsReflector(), + ); + + app.get('/angel', (req, res) { + res.write('Angel embedded within shelf!'); + return false; + }); + + app.get('/hello', ioc((@Query('name') String name) { + return {'hello': name}; + })); + + // Next, create an AngelShelf driver. + // + // If we have startup hooks we want to run, we need to call + // `startServer`. Otherwise, it can be omitted. + // Of course, if you call `startServer`, know that to run + // shutdown/cleanup logic, you need to call `close` eventually, + // too. + var angelShelf = AngelShelf(app); + await angelShelf.startServer(); + + // Create, and mount, a shelf pipeline... + // You can also embed Angel as a middleware... + var mwHandler = shelf.Pipeline() + .addMiddleware(angelShelf.middleware) + .addHandler(createStaticHandler('.', + defaultDocument: 'index.html', listDirectories: true)); + + // Run the servers. + await shelf_io.serve(mwHandler, InternetAddress.loopbackIPv4, 8080); + await shelf_io.serve(angelShelf.handler, InternetAddress.loopbackIPv4, 8081); + print('Angel as middleware: http://localhost:8080'); + print('Angel as only handler: http://localhost:8081'); +} diff --git a/packages/shelf/example/main.dart b/packages/shelf/example/main.dart new file mode 100644 index 00000000..6a7b0fb5 --- /dev/null +++ b/packages/shelf/example/main.dart @@ -0,0 +1,35 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_shelf/angel_shelf.dart'; +import 'package:logging/logging.dart'; +import 'package:pretty_logging/pretty_logging.dart'; +import 'package:shelf_static/shelf_static.dart'; + +main() async { + Logger.root + ..level = Level.ALL + ..onRecord.listen(prettyLog); + + var app = Angel(logger: Logger('angel_shelf_demo')); + var http = AngelHttp(app); + + // `shelf` request handler + var shelfHandler = createStaticHandler('.', + defaultDocument: 'index.html', listDirectories: true); + + // Use `embedShelf` to adapt a `shelf` handler for use within Angel. + var wrappedHandler = embedShelf(shelfHandler); + + // A normal Angel route. + app.get('/angel', (req, ResponseContext res) { + res.write('Hooray for `package:angel_shelf`!'); + return false; // End execution of handlers, so we don't proxy to dartlang.org when we don't need to. + }); + + // Pass any other request through to the static file handler + app.fallback(wrappedHandler); + + await http.startServer(InternetAddress.loopbackIPv4, 8080); + print('Running at ${http.uri}'); +} diff --git a/packages/shelf/lib/angel_shelf.dart b/packages/shelf/lib/angel_shelf.dart new file mode 100644 index 00000000..e251ade2 --- /dev/null +++ b/packages/shelf/lib/angel_shelf.dart @@ -0,0 +1,5 @@ +export 'src/convert.dart'; +export 'src/embed_shelf.dart'; +export 'src/shelf_driver.dart'; +export 'src/shelf_request.dart'; +export 'src/shelf_response.dart'; diff --git a/packages/shelf/lib/src/convert.dart b/packages/shelf/lib/src/convert.dart new file mode 100644 index 00000000..da418a67 --- /dev/null +++ b/packages/shelf/lib/src/convert.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:path/path.dart' as p; +import 'package:shelf/shelf.dart' as shelf; +import 'package:stream_channel/stream_channel.dart'; + +/// Creates a [shelf.Request] analogous to the input [req]. +/// +/// The request's `context` will contain [req.container] as `angel_shelf.container`, 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* set `keepRawRequestBuffers` to `true` +/// on your application instance. +Future convertRequest(RequestContext req, ResponseContext res, + {String handlerPath, Map context}) async { + var app = req.app; + var headers = {}; + req.headers.forEach((k, v) { + headers[k] = v.join(','); + }); + + headers.remove(HttpHeaders.transferEncodingHeader); + + void Function(void Function(StreamChannel>)) onHijack; + String protocolVersion; + Uri requestedUri; + + protocolVersion = '1.1'; + requestedUri = Uri.parse('http://${req.hostname}'); + requestedUri = requestedUri.replace(path: req.uri.path); + + onHijack = (void Function(StreamChannel> channel) hijack) async { + try { + print('a'); + await res.detach(); + print('b'); + var ctrl = StreamChannelController>(); + if (req.hasParsedBody) { + req.body.listen(ctrl.local.sink.add, + onError: ctrl.local.sink.addError, onDone: ctrl.local.sink.close); + } else { + await ctrl.local.sink.close(); + } + scheduleMicrotask(() => ctrl.local.stream.pipe(res)); + hijack(ctrl.foreign); + } catch (e, st) { + app.logger + ?.severe('An error occurred while hijacking a shelf request', e, st); + } + }; + + var url = req.uri; + + if (p.isAbsolute(url.path)) { + url = url.replace(path: url.path.substring(1)); + } + + return shelf.Request(req.method, requestedUri, + protocolVersion: protocolVersion, + headers: headers, + handlerPath: handlerPath, + url: url, + body: req.body, + context: {'angel_shelf.request': req} + ..addAll({'angel_shelf.container': req.container}) + ..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) { + angelResponse.headers.addAll(shelfResponse.headers); + angelResponse.statusCode = shelfResponse.statusCode; + angelResponse.properties['shelf_context'] = shelfResponse.context; + angelResponse.properties['shelf_response'] = shelfResponse; + return shelfResponse.read().pipe(angelResponse); +} diff --git a/packages/shelf/lib/src/embed_shelf.dart b/packages/shelf/lib/src/embed_shelf.dart new file mode 100644 index 00000000..9b42642c --- /dev/null +++ b/packages/shelf/lib/src/embed_shelf.dart @@ -0,0 +1,35 @@ +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, res, + 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 AngelHttpException('Internal Server Error'); + } + await mergeShelfResponse(result, res); + return false; + } on shelf.HijackException { + // On hijack, do nothing, because the hijack handlers already call res.detach(); + return null; + } catch (e) { + rethrow; + } + }; +} diff --git a/packages/shelf/lib/src/shelf_driver.dart b/packages/shelf/lib/src/shelf_driver.dart new file mode 100644 index 00000000..b0d47aa9 --- /dev/null +++ b/packages/shelf/lib/src/shelf_driver.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'shelf_request.dart'; +import 'shelf_response.dart'; + +class AngelShelf extends Driver, ShelfRequestContext, ShelfResponseContext> { + final StreamController incomingRequests = StreamController(); + + final FutureOr Function() notFound; + + AngelShelf(Angel app, {FutureOr Function() notFound}) + : this.notFound = + notFound ?? (() => shelf.Response.notFound('Not Found')), + super(app, null, useZone: false) { + // Inject a final handler that will keep responses open, if we are using the + // driver as a middleware. + app.fallback((req, res) { + if (res is ShelfResponseContext) { + res.closeSilently(); + } + return true; + }); + } + + Future> close() { + incomingRequests.close(); + return super.close(); + } + + Future> Function(dynamic, int) get serverGenerator => + (_, __) async => incomingRequests.stream; + + static UnsupportedError _unsupported() => UnsupportedError( + 'AngelShelf cannot mount a standalone server, or return a URI.'); + + Future handler(shelf.Request request) async { + var response = ShelfResponseContext(app); + var result = await handleRawRequest(request, response); + if (result is shelf.Response) { + return result; + } else if (!response.isOpen) { + return response.shelfResponse; + } else { + // return await handler(request); + return notFound(); + } + } + + shelf.Handler middleware(shelf.Handler handler) { + return (request) async { + var response = ShelfResponseContext(app); + var result = await handleRawRequest(request, response); + if (result is shelf.Response) { + return result; + } else if (!response.isOpen) { + return response.shelfResponse; + } else { + return await handler(request); + } + }; + } + + @override + Future handleAngelHttpException( + AngelHttpException e, + StackTrace st, + RequestContext req, + ResponseContext res, + shelf.Request request, + ShelfResponseContext response, + {bool ignoreFinalizers = false}) async { + await super.handleAngelHttpException(e, st, req, res, request, response, + ignoreFinalizers: ignoreFinalizers); + return response.shelfResponse; + } + + @override + void addCookies(ShelfResponseContext response, Iterable cookies) { + // Don't do anything here, otherwise you get duplicate cookies. + // response.cookies.addAll(cookies); + } + + @override + Future closeResponse(ShelfResponseContext response) { + return response.close(); + } + + @override + Uri get uri => throw _unsupported(); + + static final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + + @override + Future createRequestContext( + shelf.Request request, ShelfResponseContext response) { + var path = request.url.path.replaceAll(_straySlashes, ''); + if (path.isEmpty) path = '/'; + var rq = + ShelfRequestContext(app, app.container.createChild(), request, path); + return Future.value(rq); + } + + @override + Future createResponseContext( + shelf.Request request, ShelfResponseContext response, + [ShelfRequestContext correspondingRequest]) { + // Return the original response. + return Future.value(response..correspondingRequest = correspondingRequest); + } + + @override + Stream createResponseStreamFromRawRequest( + shelf.Request request) { + return Stream.fromIterable([null]); + } + + @override + void setChunkedEncoding(ShelfResponseContext response, bool value) { + response.chunked = value; + } + + @override + void setContentLength(ShelfResponseContext response, int length) { + response.contentLength = length; + } + + @override + void setHeader(ShelfResponseContext response, String key, String value) { + response.headers[key] = value; + } + + @override + void setStatusCode(ShelfResponseContext response, int value) { + response.statusCode = value; + } + + @override + void writeStringToResponse(ShelfResponseContext response, String value) { + response.write(value); + } + + @override + void writeToResponse(ShelfResponseContext response, List data) { + response.add(data); + } +} diff --git a/packages/shelf/lib/src/shelf_request.dart b/packages/shelf/lib/src/shelf_request.dart new file mode 100644 index 00000000..fffd6223 --- /dev/null +++ b/packages/shelf/lib/src/shelf_request.dart @@ -0,0 +1,77 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:angel_container/angel_container.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:shelf/shelf.dart' as shelf; + +class ShelfRequestContext extends RequestContext { + @override + final Angel app; + @override + final Container container; + @override + final shelf.Request rawRequest; + @override + final String path; + + List _cookies; + + @override + final MockHttpHeaders headers = MockHttpHeaders(); + + ShelfRequestContext(this.app, this.container, this.rawRequest, this.path) { + rawRequest.headers.forEach(headers.add); + } + + @override + Stream> get body => rawRequest.read(); + + @override + List get cookies { + if (_cookies == null) { + // Parse cookies + _cookies = []; + var value = headers.value('cookie'); + if (value != null) { + var cookieStrings = value.split(';').map((s) => s.trim()); + + for (var cookieString in cookieStrings) { + try { + _cookies.add(Cookie.fromSetCookieValue(cookieString)); + } catch (_) { + // Ignore malformed cookies, and just don't add them to the container. + } + } + } + } + return _cookies; + } + + @override + String get hostname => rawRequest.headers['host']; + + @override + String get method { + if (!app.allowMethodOverrides) { + return originalMethod; + } else { + return headers.value('x-http-method-override')?.toUpperCase() ?? + originalMethod; + } + } + + @override + String get originalMethod => rawRequest.method; + + @override + // TODO: implement remoteAddress + InternetAddress get remoteAddress => null; + + @override + // TODO: implement session + HttpSession get session => null; + + @override + Uri get uri => rawRequest.url; +} diff --git a/packages/shelf/lib/src/shelf_response.dart b/packages/shelf/lib/src/shelf_response.dart new file mode 100644 index 00000000..c3d180a2 --- /dev/null +++ b/packages/shelf/lib/src/shelf_response.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'shelf_request.dart'; + +class ShelfResponseContext extends ResponseContext { + final Angel app; + final StreamController> _ctrl = StreamController(); + bool _isOpen = true, + _isDetached = false, + _wasClosedByHandler = false, + _handlersAreDone = false; + + ShelfResponseContext(this.app); + + ShelfRequestContext _correspondingRequest; + + bool get wasClosedByHandler => _wasClosedByHandler; + + void closeSilently() => _handlersAreDone = true; + + ShelfRequestContext get correspondingRequest => _correspondingRequest; + + set correspondingRequest(ShelfRequestContext value) { + if (_correspondingRequest == null) { + _correspondingRequest = value; + } else { + throw StateError('The corresponding request has already been assigned.'); + } + } + + shelf.Response get shelfResponse { + return shelf.Response(statusCode, body: _ctrl.stream, headers: headers); + } + + @override + Future close() { + if (!_handlersAreDone) { + _isOpen = false; + } + _ctrl.close(); + return super.close(); + } + + Iterable __allowedEncodings; + + Iterable get _allowedEncodings { + return __allowedEncodings ??= correspondingRequest.headers + .value('accept-encoding') + ?.split(',') + ?.map((s) => s.trim()) + ?.where((s) => s.isNotEmpty) + ?.map((str) { + // Ignore quality specifications in accept-encoding + // ex. gzip;q=0.8 + if (!str.contains(';')) return str; + return str.split(';')[0]; + }); + } + + @override + Future addStream(Stream> stream) { + if (!isOpen && isBuffered) throw ResponseContext.closed(); + Stream> output = stream; + + if (encoders.isNotEmpty && correspondingRequest != null) { + if (_allowedEncodings != null) { + for (var encodingName in _allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + encoder = encoders[encodingName]; + } else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + output = encoders[key].bind(output); + break; + } + } + } + } + + return _ctrl.addStream(output); + } + + @override + void add(List data) { + if (!isOpen && isBuffered) { + throw ResponseContext.closed(); + } else if (_isOpen) { + if (encoders.isNotEmpty && correspondingRequest != null) { + if (_allowedEncodings != null) { + for (var encodingName in _allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + encoder = encoders[encodingName]; + } else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + data = encoders[key].convert(data); + break; + } + } + } + } + + _ctrl.add(data); + } + } + + @override + BytesBuilder get buffer => null; + + @override + FutureOr detach() { + _isDetached = true; + return this; + } + + @override + bool get isBuffered => false; + + @override + bool get isOpen => _isOpen && !_isDetached; + + @override + void useBuffer() {} + + @override + ShelfResponseContext get rawResponse => this; +} diff --git a/packages/shelf/pubspec.yaml b/packages/shelf/pubspec.yaml new file mode 100644 index 00000000..d4c5d66b --- /dev/null +++ b/packages/shelf/pubspec.yaml @@ -0,0 +1,18 @@ +author: Tobe O +description: Shelf interop with Angel. Use this to wrap existing server code. +homepage: https://github.com/angel-dart/shelf +name: angel_shelf +version: 2.1.0 +dependencies: + angel_framework: ^2.0.0-alpha + path: ^1.0.0 + shelf: ^0.7.0 + stream_channel: ^1.0.0 +dev_dependencies: + angel_test: ^2.0.0-alpha + pedantic: ^1.0.0 + pretty_logging: ^1.0.0 + shelf_static: ^0.2.8 + test: ^1.0.0 +environment: + sdk: ">=2.0.0-dev <3.0.0" diff --git a/packages/shelf/test/all.dart b/packages/shelf/test/all.dart new file mode 100644 index 00000000..136a7210 --- /dev/null +++ b/packages/shelf/test/all.dart @@ -0,0 +1,6 @@ +import 'package:test/test.dart'; +import 'embed_shelf_test.dart' as embed_shelf; + +main() { + group('embed_shelf', embed_shelf.main); +} diff --git a/packages/shelf/test/embed_shelf_test.dart b/packages/shelf/test/embed_shelf_test.dart new file mode 100644 index 00000000..ae0f7a62 --- /dev/null +++ b/packages/shelf/test/embed_shelf_test.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_shelf/angel_shelf.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:charcode/charcode.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:pretty_logging/pretty_logging.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +main() { + http.Client client; + HttpServer server; + String url; + + String _path(String p) { + return Uri( + scheme: 'http', + host: server.address.address, + port: server.port, + path: p) + .toString(); + } + + setUp(() async { + client = http.Client(); + var handler = shelf.Pipeline().addHandler((shelf.Request request) { + if (request.url.path == 'two') { + return shelf.Response(200, body: json.encode(2)); + } else if (request.url.path == 'error') { + throw AngelHttpException.notFound(); + } else if (request.url.path == 'status') { + return shelf.Response.notModified(headers: {'foo': 'bar'}); + } else if (request.url.path == 'hijack') { + request.hijack((StreamChannel> channel) { + print('a'); + var sink = channel.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(); + print('b'); + }); + return null; + } else if (request.url.path == 'throw') { + return null; + } else { + return shelf.Response.ok('Request for "${request.url}"'); + } + }); + + var logger = Logger.detached('angel_shelf')..onRecord.listen(prettyLog); + var app = Angel(logger: logger); + var httpDriver = AngelHttp(app); + app.get('/angel', (req, res) => 'Angel'); + app.fallback(embedShelf(handler, throwOnNullResponse: true)); + + server = await httpDriver.startServer(InternetAddress.loopbackIPv4, 0); + }); + + tearDown(() async { + await client.close(); + await server.close(force: true); + }); + + test('expose angel side', () async { + var response = await client.get(_path('/angel')); + expect(json.decode(response.body), equals('Angel')); + }); + + test('expose shelf side', () async { + var response = await client.get(_path('/foo')); + expect(response, hasStatus(200)); + expect(response.body, equals('Request for "foo"')); + }); + + test('shelf can return arbitrary values', () async { + var response = await client.get(_path('/two')); + expect(response, isJson(2)); + }); + + test('shelf can hijack', () async { + try { + var client = HttpClient(); + var rq = await client.openUrl('GET', Uri.parse(_path('/hijack'))); + var rs = await rq.close(); + var body = await rs.cast>().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(_path('/status')); + expect(response, allOf(hasStatus(304), hasHeader('foo', 'bar'))); + }); + + test('shelf can throw error', () async { + var response = await client.get(_path('/error')); + expect(response, hasStatus(404)); + }); + + test('throw on null', () async { + var response = await client.get(_path('/throw')); + expect(response, hasStatus(500)); + }); +}