diff --git a/example/hostname.dart b/example/hostname.dart new file mode 100644 index 00000000..bd6bcad8 --- /dev/null +++ b/example/hostname.dart @@ -0,0 +1,43 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:logging/logging.dart'; + +Future apiConfigurer(Angel app) async { + app.get('/', (req, res) => 'Hello, API!'); + app.fallback((req, res) { + return 'fallback on ${req.uri} (within the API)'; + }); +} + +Future frontendConfigurer(Angel app) async { + app.fallback((req, res) => '(usually an index page would be shown here.)'); +} + +main() async { + // Logging set up/boilerplate + Logger.root.onRecord.listen(print); + + var app = Angel(logger: Logger('angel')); + var http = AngelHttp(app); + var multiHost = HostnameRouter.configure({ + 'api.localhost:3000': apiConfigurer, + 'localhost:3000': frontendConfigurer, + }); + + app + ..fallback(multiHost.handleRequest) + ..fallback((req, res) { + res.write('Uncaught hostname: ${req.hostname}'); + }); + + app.errorHandler = (e, req, res) { + print(e.message ?? e.error ?? e); + print(e.stackTrace); + return e.toJson(); + }; + + await http.startServer('127.0.0.1', 3000); + print('Listening at ${http.uri}'); + print('(See what happens when you visit http://localhost:3000 instead)'); +} diff --git a/lib/src/core/core.dart b/lib/src/core/core.dart index 755ae9ae..a7a0da4f 100644 --- a/lib/src/core/core.dart +++ b/lib/src/core/core.dart @@ -3,6 +3,8 @@ export 'controller.dart'; export 'driver.dart'; export 'env.dart'; export 'hooked_service.dart'; +export 'hostname_parser.dart'; +export 'hostname_router.dart'; export 'map_service.dart'; export 'metadata.dart'; export 'request_context.dart'; diff --git a/lib/src/core/hostname_parser.dart b/lib/src/core/hostname_parser.dart index 7f44112c..5b3540fb 100644 --- a/lib/src/core/hostname_parser.dart +++ b/lib/src/core/hostname_parser.dart @@ -4,9 +4,16 @@ import 'package:string_scanner/string_scanner.dart'; /// Parses a string into a [RegExp] that is matched against hostnames. class HostnameSyntaxParser { final SpanScanner _scanner; - var _safe = RegExp(r"[0-9a-zA-Z-_]+"); + var _safe = RegExp(r"[0-9a-zA-Z-_:]+"); - HostnameSyntaxParser(String hostname) : _scanner = SpanScanner(hostname); + HostnameSyntaxParser(String hostname) + : _scanner = SpanScanner(hostname, sourceUrl: hostname); + + FormatException _formatExc(String message) { + var span = _scanner.lastSpan ?? _scanner.emptySpan; + return FormatException( + '${span.start.toolString}: $message\n' + span.highlight(color: true)); + } RegExp parse() { var b = StringBuffer(); @@ -15,13 +22,11 @@ class HostnameSyntaxParser { while (!_scanner.isDone) { if (_scanner.scan('|')) { if (parts.isEmpty) { - throw FormatException( - '${_scanner.emptySpan.end.toolString}: No hostname parts found before "|".'); + throw _formatExc('No hostname parts found before "|".'); } else { var next = _parseHostnamePart(); if (next == null) { - throw FormatException( - '${_scanner.emptySpan.end.toolString}: No hostname parts found after "|".'); + throw _formatExc('No hostname parts found after "|".'); } else { var prev = parts.removeLast(); parts.addLast('(($prev)|($next))'); @@ -30,6 +35,18 @@ class HostnameSyntaxParser { } else { var part = _parseHostnamePart(); if (part != null) { + if (_scanner.scan('.')) { + var subPart = _parseHostnamePart(shouldThrow: false); + while (subPart != null) { + part += '\\.' + subPart; + if (_scanner.scan('.')) { + subPart = _parseHostnamePart(shouldThrow: false); + } else { + break; + } + } + } + parts.add(part); } } @@ -40,23 +57,24 @@ class HostnameSyntaxParser { } if (b.isEmpty) { - throw FormatException('Invalid or empty hostname.'); + throw _formatExc('Invalid or empty hostname.'); } else { - return RegExp(b.toString(), caseSensitive: false); + return RegExp('^$b\$', caseSensitive: false); } } - String _parseHostnamePart() { + String _parseHostnamePart({bool shouldThrow = true}) { if (_scanner.scan('*.')) { - return r'([^$]+\.)?'; + return r'([^$.]+\.)?'; } else if (_scanner.scan('*')) { return r'[^$]*'; + } else if (_scanner.scan('+')) { + return r'[^$]+'; } else if (_scanner.scan(_safe)) { return _scanner.lastMatch[0]; - } else if (!_scanner.isDone) { + } else if (!_scanner.isDone && shouldThrow) { var s = String.fromCharCode(_scanner.peekChar()); - throw FormatException( - '${_scanner.emptySpan.end.toolString}: Unexpected character "$s".'); + throw _formatExc('Unexpected character "$s".'); } else { return null; } diff --git a/lib/src/core/hostname_router.dart b/lib/src/core/hostname_router.dart index 3e7951ed..c6e98f70 100644 --- a/lib/src/core/hostname_router.dart +++ b/lib/src/core/hostname_router.dart @@ -1,5 +1,122 @@ import 'dart:async'; -import 'package:string_scanner/string_scanner.dart'; +import 'package:angel_container/angel_container.dart'; +import 'package:angel_route/angel_route.dart'; +import 'package:logging/logging.dart'; +import 'env.dart'; +import 'hostname_parser.dart'; import 'request_context.dart'; import 'response_context.dart'; +import 'routable.dart'; import 'server.dart'; + +/// A utility that allows requests to be handled based on their +/// origin's hostname. +/// +/// For example, an application could handle example.com and api.example.com +/// separately. +/// +/// The provided patterns can be any `Pattern`. If a `String` is provided, a simple +/// grammar (see [HostnameSyntaxParser]) is used to create [RegExp]. +/// +/// For example: +/// * `example.com` -> `/example\.com/` +/// * `*.example.com` -> `/([^$.]\.)?example\.com/` +/// * `example.*` -> `/example\./[^$]*` +/// * `example.+` -> `/example\./[^$]+` +class HostnameRouter { + final Map _apps = {}; + final Map Function()> _creators = {}; + final List _patterns = []; + + HostnameRouter( + {Map apps = const {}, + Map Function()> creators = const {}}) { + Map _parseMap(Map map) { + return map.map((p, c) { + Pattern pp; + + if (p is String) { + pp = HostnameSyntaxParser(p).parse(); + } else { + pp = p; + } + + return MapEntry(pp, c); + }); + } + + apps ??= {}; + creators ??= {}; + apps = _parseMap(apps); + creators = _parseMap(creators); + var patterns = apps.keys.followedBy(creators.keys).toSet().toList(); + _apps.addAll(apps); + _creators.addAll(creators); + _patterns.addAll(patterns); + // print(_creators); + } + + factory HostnameRouter.configure( + Map Function(Angel)> configurers, + {Reflector reflector = const EmptyReflector(), + AngelEnvironment environment = angelEnv, + Logger logger, + bool allowMethodOverrides = true, + FutureOr Function(dynamic) serializer, + ViewGenerator viewGenerator}) { + var creators = configurers.map((p, c) { + return MapEntry(p, () async { + var app = Angel( + reflector: reflector, + environment: environment, + logger: logger, + allowMethodOverrides: allowMethodOverrides, + serializer: serializer, + viewGenerator: viewGenerator); + await app.configure(c); + return app; + }); + }); + return HostnameRouter(creators: creators); + } + + /// Attempts to handle a request, according to its hostname. + /// + /// If none is matched, then `true` is returned. + /// Also returns `true` if all of the sub-app's handlers returned + /// `true`. + Future handleRequest(RequestContext req, ResponseContext res) async { + if (req.hostname != null) { + for (var pattern in _patterns) { + // print('${req.hostname} vs $_creators'); + if (pattern.allMatches(req.hostname).isNotEmpty) { + // Resolve the entire pipeline within the context of the selected app. + var app = _apps[pattern] ??= (await _creators[pattern]()); + // print('App for ${req.hostname} = $app from $pattern'); + // app.dumpTree(); + + var r = app.optimizedRouter; + var resolved = + r.resolveAbsolute(req.path, method: req.method); + var pipeline = MiddlewarePipeline(resolved); + // print('Pipeline: $pipeline'); + for (var handler in pipeline.handlers) { + // print(handler); + // Avoid stack overflow. + if (handler == handleRequest) { + continue; + } else if (!await app.executeHandler(handler, req, res)) { + // print('$handler TERMINATED'); + return false; + } else { + // print('$handler CONTINUED'); + } + } + } + } + } + + // Otherwise, return true. + return true; + } +}