From 3b83e34dcc9708b9037a7c20ca9cce081191d4a3 Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Sun, 22 Sep 2024 18:46:14 -0700 Subject: [PATCH] add(angel3): adding re-branded angel3 route package --- packages/route/.gitignore | 71 +++ packages/route/AUTHORS.md | 12 + packages/route/CHANGELOG.md | 98 ++++ packages/route/LICENSE | 29 ++ packages/route/README.md | 136 +++++ packages/route/analysis_options.yaml | 1 + packages/route/example/main.dart | 58 +++ packages/route/lib/browser.dart | 225 ++++++++ packages/route/lib/platform_route.dart | 5 + packages/route/lib/src/grammar.dart | 327 ++++++++++++ .../route/lib/src/middleware_pipeline.dart | 50 ++ packages/route/lib/src/route.dart | 99 ++++ packages/route/lib/src/router.dart | 493 ++++++++++++++++++ packages/route/lib/src/routing_exception.dart | 21 + packages/route/lib/src/routing_result.dart | 95 ++++ packages/route/lib/src/symlink_route.dart | 8 + packages/route/lib/string_util.dart | 43 ++ packages/route/pubspec.yaml | 17 + packages/route/repubspec.yaml | 2 + packages/route/test/chain_nest_test.dart | 19 + packages/route/test/navigate_test.dart | 44 ++ packages/route/test/params_test.dart | 45 ++ packages/route/test/parse_test.dart | 20 + packages/route/test/root_test.dart | 15 + packages/route/test/server_test.dart | 205 ++++++++ packages/route/test/strip_test.dart | 46 ++ packages/route/test/uri_decode_test.dart | 18 + packages/route/test/wildcard_test.dart | 46 ++ packages/route/web/hash/basic.dart | 4 + packages/route/web/hash/basic.html | 30 ++ packages/route/web/index.html | 15 + packages/route/web/push_state/basic.dart | 4 + packages/route/web/push_state/basic.html | 31 ++ packages/route/web/shared/basic.dart | 42 ++ 34 files changed, 2374 insertions(+) create mode 100644 packages/route/.gitignore create mode 100644 packages/route/AUTHORS.md create mode 100644 packages/route/CHANGELOG.md create mode 100644 packages/route/LICENSE create mode 100644 packages/route/README.md create mode 100644 packages/route/analysis_options.yaml create mode 100644 packages/route/example/main.dart create mode 100644 packages/route/lib/browser.dart create mode 100644 packages/route/lib/platform_route.dart create mode 100644 packages/route/lib/src/grammar.dart create mode 100644 packages/route/lib/src/middleware_pipeline.dart create mode 100644 packages/route/lib/src/route.dart create mode 100644 packages/route/lib/src/router.dart create mode 100644 packages/route/lib/src/routing_exception.dart create mode 100644 packages/route/lib/src/routing_result.dart create mode 100644 packages/route/lib/src/symlink_route.dart create mode 100644 packages/route/lib/string_util.dart create mode 100644 packages/route/pubspec.yaml create mode 100644 packages/route/repubspec.yaml create mode 100644 packages/route/test/chain_nest_test.dart create mode 100644 packages/route/test/navigate_test.dart create mode 100644 packages/route/test/params_test.dart create mode 100644 packages/route/test/parse_test.dart create mode 100644 packages/route/test/root_test.dart create mode 100644 packages/route/test/server_test.dart create mode 100644 packages/route/test/strip_test.dart create mode 100644 packages/route/test/uri_decode_test.dart create mode 100644 packages/route/test/wildcard_test.dart create mode 100644 packages/route/web/hash/basic.dart create mode 100644 packages/route/web/hash/basic.html create mode 100644 packages/route/web/index.html create mode 100644 packages/route/web/push_state/basic.dart create mode 100644 packages/route/web/push_state/basic.html create mode 100644 packages/route/web/shared/basic.dart diff --git a/packages/route/.gitignore b/packages/route/.gitignore new file mode 100644 index 0000000..24d6831 --- /dev/null +++ b/packages/route/.gitignore @@ -0,0 +1,71 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.dart_tool +.packages +.pub/ +build/ + +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ + +### Dart template +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub + +# SDK 1.20 and later (no longer creates packages directories) + +# Older SDK versions +# (Include if the minimum SDK version specified in pubsepc.yaml is earlier than 1.20) +.project +.buildlog +**/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 + +# Don't commit pubspec lock file +# (Library packages only! Remove pattern if developing an application package) +### 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: + +## VsCode +.vscode/ + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +.idea/ +/out/ +.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/packages/route/AUTHORS.md b/packages/route/AUTHORS.md new file mode 100644 index 0000000..ac95ab5 --- /dev/null +++ b/packages/route/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/route/CHANGELOG.md b/packages/route/CHANGELOG.md new file mode 100644 index 0000000..34b37d6 --- /dev/null +++ b/packages/route/CHANGELOG.md @@ -0,0 +1,98 @@ +# Change Log + +## 8.1.1 + +* Updated repository link + +## 8.1.0 + +* Updated `lints` to 3.0.0 +* Fixed analyser warnings + +## 8.0.0 + +* Require Dart >= 3.0 +* Updated `build_web_compilers` to 4.0.0 +* Updated `http` to 1.0.0 + +## 7.0.0 + +* Require Dart >= 2.17 + +## 6.0.0 + +* Updated to 2.16.x + +## 5.2.0 + +* Updated `package:build_runner` +* Updated `package:build_web_compiler` + +## 5.1.0 + +* Updated to use `package:belatuk_combinator` +* Updated linter to `package:lints` + +## 5.0.1 + +* Updated README + +## 5.0.0 + +* Migrated to support Dart >= 2.12 NNBD + +## 4.0.0 + +* Migrated to work with Dart >= 2.12 Non NNBD + +## 3.1.0+1 + +* Accidentally hit `CTRL-C` while uploading `3.1.0`; this version ensures everything is ok. + +## 3.1.0 + +* Add `Router.groupAsync` + +## 3.0.6 + +* Remove static default values for `middleware`. + +## 3.0.5 + +* Add `MiddlewarePipelineIterator`. + +## 3.0.4 + +* Add `RouteResult` class, which allows segments (i.e. wildcard) to +modify the `tail`. +* Add more wildcard tests. + +## 3.0.3 + +* Support trailing text after parameters with custom Regexes. + +## 3.0.2 + +* Support leading and trailing text for both `:parameters` and `*` + +## 3.0.1 + +* Make the callback in `Router.group` generically-typed. + +## 3.0.0 + +* Make `Router` and `Route` single-parameter generic. +* Remove `package:browser` dependency. +* `BrowserRouter.on` now only accepts a `String`. +* `MiddlewarePipeline.routingResults` now accepts +an `Iterable`, instead of just a `List`. +* Removed deprecated `Route.as`, as well as `Router.registerMiddleware`. +* Completely removed `Route.requestMiddleware`. + +## 2.0.7 + +* Minor strong mode updates to work with stricter Dart 2. + +## 2.0.5 + +* Patch to work with `combinator@1.0.0`. diff --git a/packages/route/LICENSE b/packages/route/LICENSE new file mode 100644 index 0000000..df5e063 --- /dev/null +++ b/packages/route/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/route/README.md b/packages/route/README.md new file mode 100644 index 0000000..0b125db --- /dev/null +++ b/packages/route/README.md @@ -0,0 +1,136 @@ +# Angel3 Route + +![Pub Version (including pre-releases)](https://img.shields.io/pub/v/angel3_route?include_prereleases) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![Gitter](https://img.shields.io/gitter/room/angel_dart/discussion)](https://gitter.im/angel_dart/discussion) +[![License](https://img.shields.io/github/license/dart-backend/angel)](https://github.com/dart-backend/angel/tree/master/packages/route/LICENSE) + +A powerful, isomorphic routing library for Dart. + +`angel3_route` exposes a routing system that takes the shape of a tree. This tree structure can be easily navigated, in a fashion somewhat similar to a filesystem. The `Router` API is a very straightforward interface that allows for your code to take a shape similar to the route tree. Users of Laravel and Express will be very happy. + +`angel3_route` does not require the use of [Angel 3](https://pub.dev/packages/angel3_framework), and has minimal dependencies. Thus, it can be used in any application, regardless of framework. This includes Web apps, Flutter apps, CLI apps, and smaller servers which do not need all the features of the Angel framework. + +## Contents + +- [Angel3 Route](#angel3-route) + - [Contents](#contents) + - [Examples](#examples) + - [Routing](#routing) + - [Hierarchy](#hierarchy) + - [In the Browser](#in-the-browser) + - [Route State](#route-state) + - [Route Parameters](#route-parameters) + +## Examples + +### Routing + +If you use [Angel3](https://pub.dev/packages/angel3_framework), every `Angel` instance is a `Router` in itself. + +```dart +void main() { + final router = Router(); + + router.get('/users', () {}); + + router.post('/users/:id/timeline', (String id) {}); + + router.get('/square_root/:id([0-9]+)', (n) { + return { 'result': pow(int.parse(n), 0.5) }; + }); + + // You can also have parameters auto-parsed. + // + // Supports int, double, and num. + router.get('/square_root/int:id([0-9]+)', (int n) { + return { 'result': pow(n, 0.5) }; + }); + + router.group('/show/:id', (router) { + router.get('/reviews', (id) { + return someQuery(id).reviews; + }); + + // Optionally restrict params to a RegExp + router.get('/reviews/:reviewId([A-Za-z0-9_]+)', (id, reviewId) { + return someQuery(id).reviews.firstWhere( + (r) => r.id == reviewId); + }); + }, middleware: [put, middleware, here]); + + // Grouping can also take async callbacks. + await router.groupAsync('/hello', (router) async { + var name = await getNameFromFileSystem(); + router.get(name, (req, res) => '...'); + }); +} +``` + +The default `Router` does not give any notification of routes being changed, because there is no inherent stream of URL's for it to listen to. This is good, because a server needs a lot of flexibility with which to handle requests. + +### Hierarchy + +```dart +void main() { + final router = Router(); + + router + .chain('middleware1') + .chain('other_middleware') + .get('/hello', () { + print('world'); + }); + + router.group('/user/:id', (router) { + router.get('/balance', (id) async { + final user = await someQuery(id); + return user.balance; + }); + }); +} +``` + +See [the tests](test/route/no_params.dart) for good examples. + +## In the Browser + +Supports both hashed routes and pushState. The `BrowserRouter` interface exposes a `Stream onRoute`, which can be listened to for changes. It will fire `"NULL"` whenever no route is matched. + +`angel3_route` will also automatically intercept `` elements and redirect them to your routes. + +To prevent this for a given anchor, do any of the following: + +- Do not provide an `href` +- Provide a `download` or `target` attribute on the element +- Set `rel="external"` + +## Route State + +```dart +main() { + final router = BrowserRouter(); + // .. + router.onRoute.listen((route) { + if (route == null) + throw 404; + else route.state['foo'] = 'bar'; + }); + + router.listen(); // Start listening +} +``` + +For applications where you need to access a chain of handlers, consider using `onResolve` instead. You can see an example in `web/shared/basic.dart`. + +## Route Parameters + +Routes can have parameters, as seen in the above examples. Use `allParams` in a `RoutingResult` to get them as a nice `Map`: + +```dart +var router = Router(); +router.get('/book/:id/authors', () => ...); + +var result = router.resolve('/book/foo/authors'); +var params = result.allParams; // {'id': 'foo'}; +``` diff --git a/packages/route/analysis_options.yaml b/packages/route/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/packages/route/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/route/example/main.dart b/packages/route/example/main.dart new file mode 100644 index 0000000..6a3c363 --- /dev/null +++ b/packages/route/example/main.dart @@ -0,0 +1,58 @@ +import 'dart:math'; + +import 'package:platform_route/platform_route.dart'; + +void main() { + final router = Router(); + + router.get('/whois/~:user', () {}); + + router.get('/wild*', () {}); + + router.get('/ordinal/int:n([0-9]+)st', () {}); + + print(router.resolveAbsolute('/whois/~thosakwe').first.allParams); + print(router.resolveAbsolute('/wild_thornberrys').first.route.path); + print(router.resolveAbsolute('/ordinal/1st').first.allParams); + + router.get('/users', () {}); + + router.post('/users/:id/timeline', (String id) {}); + + router.get('/square_root/:id([0-9]+)', (String n) { + return {'result': pow(int.parse(n), 0.5)}; + }); + + // You can also have parameters auto-parsed. + // + // Supports int, double, and num. + router.get('/square_root/int:id([0-9]+)', (int n) { + return {'result': pow(n, 0.5)}; + }); + + router.group('/show/:id', (router) { + router.get('/reviews', (id) { + return someQuery(id).reviews; + }); + + // Optionally restrict params to a RegExp + router.get('/reviews/:reviewId([A-Za-z0-9_]+)', (id, reviewId) { + return someQuery(id).reviews.firstWhere((r) => r.id == reviewId); + }); + }); +} + +SomeQuery someQuery(id) => SomeQuery(); + +class SomeQuery { + List get reviews => [ + SomeQueryReview('fake'), + SomeQueryReview('data'), + ]; +} + +class SomeQueryReview { + final String id; + + SomeQueryReview(this.id); +} diff --git a/packages/route/lib/browser.dart b/packages/route/lib/browser.dart new file mode 100644 index 0000000..e8e995f --- /dev/null +++ b/packages/route/lib/browser.dart @@ -0,0 +1,225 @@ +import 'dart:async' show Stream, StreamController; +import 'dart:html'; +import 'package:path/path.dart' as p; + +import 'platform_route.dart'; + +final RegExp _hash = RegExp(r'^#/'); +final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + +/// A variation of the [Router] support both hash routing and push state. +abstract class BrowserRouter extends Router { + /// Fires whenever the active route changes. Fires `null` if none is selected (404). + Stream> get onResolve; + + /// Fires whenever the active route changes. Fires `null` if none is selected (404). + Stream> get onRoute; + + /// Set `hash` to true to use hash routing instead of push state. + /// `listen` as `true` will call `listen` after initialization. + factory BrowserRouter({bool hash = false, bool listen = false}) { + return hash + ? _HashRouter(listen: listen) + : _PushStateRouter(listen: listen); + } + + //BrowserRouter._() : super(); + + void _goTo(String path); + + /// Navigates to the path generated by calling + /// [navigate] with the given [linkParams]. + /// + /// This always navigates to an absolute path. + void go(List linkParams); + + // Handles a route path, manually. + // void handle(String path); + + /// Begins listen for location changes. + void listen(); + + /// Identical to [all]. + Route on(String path, T handler, {Iterable middleware}); +} + +abstract class _BrowserRouterImpl extends Router + implements BrowserRouter { + bool _listening = false; + Route? _current; + final StreamController> _onResolve = + StreamController>(); + final StreamController> _onRoute = StreamController>(); + + Route? get currentRoute => _current; + + @override + Stream> get onResolve => _onResolve.stream; + + @override + Stream> get onRoute => _onRoute.stream; + + _BrowserRouterImpl({bool listen = false}) : super() { + if (listen != false) this.listen(); + prepareAnchors(); + } + + @override + void go(Iterable linkParams) => _goTo(navigate(linkParams)); + + @override + Route on(String path, T handler, {Iterable middleware = const []}) => + all(path, handler, middleware: middleware); + + void prepareAnchors() { + final anchors = window.document + .querySelectorAll('a') + .cast(); //:not([dynamic])'); + + for (final $a in anchors) { + if ($a.attributes.containsKey('href') && + $a.attributes.containsKey('download') && + $a.attributes.containsKey('target') && + $a.attributes['rel'] != 'external') { + $a.onClick.listen((e) { + e.preventDefault(); + _goTo($a.attributes['href']!); + //go($a.attributes['href'].split('/').where((str) => str.isNotEmpty)); + }); + } + + $a.attributes['dynamic'] = 'true'; + } + } + + void _listen(); + + @override + void listen() { + if (_listening) { + throw StateError('The router is already listening for page changes.'); + } + _listening = true; + _listen(); + } +} + +class _HashRouter extends _BrowserRouterImpl { + _HashRouter({required bool listen}) : super(listen: listen) { + if (listen) { + this.listen(); + } + } + + @override + void _goTo(String uri) { + window.location.hash = '#$uri'; + } + + void handleHash([_]) { + final path = window.location.hash.replaceAll(_hash, ''); + var allResolved = resolveAbsolute(path); + + if (allResolved.isEmpty) { + // TODO: Need fixing + //_onResolve.add(null); + //_onRoute.add(_current = null); + _current = null; + } else { + var resolved = allResolved.first; + if (resolved.route != _current) { + _onResolve.add(resolved); + _onRoute.add(_current = resolved.route); + } + } + } + + void handlePath(String path) { + final resolved = resolveAbsolute(path).first; + + //if (resolved == null) { + // _onResolve.add(null); + // _onRoute.add(_current = null); + //} else + if (resolved.route != _current) { + _onResolve.add(resolved); + _onRoute.add(_current = resolved.route); + } + } + + @override + void _listen() { + window.onHashChange.listen(handleHash); + handleHash(); + } +} + +class _PushStateRouter extends _BrowserRouterImpl { + late String _basePath; + + _PushStateRouter({required bool listen}) : super(listen: listen) { + var $base = window.document.querySelector('base[href]') as BaseElement; + + if ($base.href.isNotEmpty != true) { + throw StateError( + 'You must have a element present in your document to run the push state router.'); + } + _basePath = $base.href.replaceAll(_straySlashes, ''); + if (listen) this.listen(); + } + + @override + void _goTo(String uri) { + final resolved = resolveAbsolute(uri).first; + var relativeUri = uri; + + if (_basePath.isNotEmpty) { + relativeUri = p.join(_basePath, uri.replaceAll(_straySlashes, '')); + } + + //if (resolved == null) { + // _onResolve.add(null); + // _onRoute.add(_current = null); + //} else { + final route = resolved.route; + var thisPath = route.name ?? ''; + if (thisPath.isEmpty) { + thisPath = route.path; + } + window.history + .pushState({'path': route.path, 'params': {}}, thisPath, relativeUri); + _onResolve.add(resolved); + _onRoute.add(_current = route); + //} + } + + void handleState(state) { + if (state is Map && state.containsKey('path')) { + var path = state['path'].toString(); + final resolved = resolveAbsolute(path).first; + + if (resolved.route != _current) { + //properties.addAll(state['properties'] ?? {}); + _onResolve.add(resolved); + _onRoute.add(_current = resolved.route); + } else { + //_onResolve.add(null); + //_onRoute.add(_current = null); + _current = null; + } + } else { + //_onResolve.add(null); + //_onRoute.add(_current = null); + _current = null; + } + } + + @override + void _listen() { + window.onPopState.listen((e) { + handleState(e.state); + }); + + handleState(window.history.state); + } +} diff --git a/packages/route/lib/platform_route.dart b/packages/route/lib/platform_route.dart new file mode 100644 index 0000000..8cd7ae4 --- /dev/null +++ b/packages/route/lib/platform_route.dart @@ -0,0 +1,5 @@ +library platform_route; + +export 'src/middleware_pipeline.dart'; +export 'src/router.dart'; +export 'src/routing_exception.dart'; diff --git a/packages/route/lib/src/grammar.dart b/packages/route/lib/src/grammar.dart new file mode 100644 index 0000000..ecacb58 --- /dev/null +++ b/packages/route/lib/src/grammar.dart @@ -0,0 +1,327 @@ +part of 'router.dart'; + +class RouteGrammar { + static const String notSlashRgx = r'([^/]+)'; + //static final RegExp rgx = RegExp(r'\((.+)\)'); + static final Parser notSlash = + match(RegExp(notSlashRgx)).value((r) => r.span?.text ?? ''); + + static final Parser regExp = + match(RegExp(r'\(([^\n)]+)\)([^/]+)?')) + .value((r) => r.scanner.lastMatch!); + + static final Parser parameterName = + match(RegExp('$notSlashRgx?' r':([A-Za-z0-9_]+)' r'([^(/\n])?')) + .value((r) => r.scanner.lastMatch!); + + static final Parser parameterSegment = chain([ + parameterName, + match('?').value((r) => true).opt(), + regExp.opt(), + ]).map((r) { + var match = r.value![0] as Match; + + var r2 = r.value![2]; + Match? rgxMatch; + if (r2 != 'NULL') { + rgxMatch = r2 as Match?; + } + + var pre = match[1] ?? ''; + var post = match[3] ?? ''; + RegExp? rgx; + + if (rgxMatch != null) { + rgx = RegExp('(${rgxMatch[1]})'); + post = (rgxMatch[2] ?? '') + post; + } + + if (pre.isNotEmpty || post.isNotEmpty) { + if (rgx != null) { + var pattern = pre + rgx.pattern + post; + rgx = RegExp(pattern); + } else { + rgx = RegExp('$pre$notSlashRgx$post'); + } + } + + // TODO: relook at this later + var m2 = match[2] ?? ''; + var s = ParameterSegment(m2, rgx); + return r.value![1] == true ? OptionalSegment(s) : s; + }); + + static final Parser parsedParameterSegment = chain([ + match(RegExp(r'(int|num|double)'), + errorMessage: 'Expected "int","double", or "num".') + .map((r) => r.span!.text), + parameterSegment, + ]).map((r) { + return ParsedParameterSegment( + r.value![0] as String, r.value![1] as ParameterSegment); + }); + + static final Parser wildcardSegment = + match(RegExp('$notSlashRgx?' r'\*' '$notSlashRgx?')) + .value((r) { + var m = r.scanner.lastMatch!; + var pre = m[1] ?? ''; + var post = m[2] ?? ''; + return WildcardSegment(pre, post); + }); + + static final Parser constantSegment = + notSlash.map((r) => ConstantSegment(r.value)); + + static final Parser slashSegment = + match(SlashSegment.rgx).map((_) => SlashSegment()); + + static final Parser routeSegment = any([ + //slashSegment, + parsedParameterSegment, + parameterSegment, + wildcardSegment, + constantSegment + ]); + + // static final Parser routeDefinition = routeSegment + // .star() + // .map((r) => RouteDefinition(r.value ?? [])) + // .surroundedBy(match(RegExp(r'/*')).opt()); + + static final Parser slashes = match(RegExp(r'/*')); + + static final Parser routeDefinition = routeSegment + .separatedBy(slashes) + .map((r) => RouteDefinition(r.value ?? [])) + .surroundedBy(slashes.opt()); +} + +class RouteDefinition { + final List segments; + + RouteDefinition(this.segments); + + Parser? compile() { + Parser? out; + + for (var i = 0; i < segments.length; i++) { + var s = segments[i]; + var isLast = i == segments.length - 1; + if (out == null) { + out = s.compile(isLast); + } else { + out = s.compileNext( + out.then(match('/')).index(0).cast(), isLast); + } + } + + return out; + } +} + +abstract class RouteSegment { + Parser compile(bool isLast); + + Parser compileNext(Parser p, bool isLast); +} + +class SlashSegment implements RouteSegment { + static final RegExp rgx = RegExp(r'/+'); + + const SlashSegment(); + + @override + Parser compile(bool isLast) { + return match(rgx).map((_) => RouteResult({})); + } + + @override + Parser compileNext(Parser p, bool isLast) { + return p.then(compile(isLast)).index(0).cast(); + } + + @override + String toString() => 'Slash'; +} + +class ConstantSegment extends RouteSegment { + final String? text; + + ConstantSegment(this.text); + + @override + String toString() { + return 'Constant: $text'; + } + + @override + Parser compile(bool isLast) { + return match(text!).map((r) => RouteResult({})); + } + + @override + Parser compileNext(Parser p, bool isLast) { + return p.then(compile(isLast)).index(0).cast(); + } +} + +class WildcardSegment extends RouteSegment { + final String pre, post; + + WildcardSegment(this.pre, this.post); + + @override + String toString() { + return 'Wildcard segment'; + } + + String _symbol(bool isLast) { + if (isLast) return r'.*'; + return r'[^/]*'; + } + + RegExp _compile(bool isLast) { + return RegExp('$pre(${_symbol(isLast)})$post'); + // if (isLast) return match(RegExp(r'.*')); + // return match(RegExp(r'[^/]*')); + } + + @override + Parser compile(bool isLast) { + return match(_compile(isLast)).map((r) { + var result = r.scanner.lastMatch; + if (result != null) { + //return RouteResult({}, tail: r.scanner.lastMatch![1]) + return RouteResult({}, tail: result[1]); + } else { + return RouteResult({}); + } + }); + } + + @override + Parser compileNext(Parser p, bool isLast) { + return p.then(compile(isLast)).map((r) { + var items = r.value!.cast(); + var a = items[0], b = items[1]; + return a + ..addAll(b.params) + .._setTail(b.tail); + }); + } +} + +class OptionalSegment extends ParameterSegment { + final ParameterSegment parameter; + + OptionalSegment(this.parameter) : super(parameter.name, parameter.regExp); + + @override + String toString() { + return 'Optional: $parameter'; + } + + @override + Parser compile(bool isLast) { + return super.compile(isLast).opt(); + } + + @override + Parser compileNext(Parser p, bool isLast) { + return p.then(_compile().opt()).map((r) { + // Return an empty RouteResult if null + if (r.value == null) { + return RouteResult({}); + } + + var v = r.value!; + + if (v[1] == null) { + return v[0] as RouteResult; + } + return (v[0] as RouteResult) + ..addAll({name: Uri.decodeComponent(v as String)}); + }); + } +} + +class ParameterSegment extends RouteSegment { + final String name; + final RegExp? regExp; + + ParameterSegment(this.name, this.regExp); + + @override + String toString() { + if (regExp != null) { + return 'Param: $name (${regExp?.pattern})'; + } + return 'Param: $name'; + } + + Parser _compile() { + if (regExp != null) { + return match(regExp!).value((r) { + var result = r.scanner.lastMatch; + if (result != null) { + // TODO: Invalid method + //return r.scanner.lastMatch![1]; + return result.toString(); + } else { + return ''; + } + }); + } else { + return RouteGrammar.notSlash; + } + } + + @override + Parser compile(bool isLast) { + return _compile() + .map((r) => RouteResult({name: Uri.decodeComponent(r.value!)})); + } + + @override + Parser compileNext(Parser p, bool isLast) { + return p.then(_compile()).map((r) { + return (r.value![0] as RouteResult) + ..addAll({name: Uri.decodeComponent(r.value![1] as String)}); + }); + } +} + +class ParsedParameterSegment extends RouteSegment { + final String type; + final ParameterSegment parameter; + + ParsedParameterSegment(this.type, this.parameter); + + num getValue(String s) { + switch (type) { + case 'int': + return int.parse(s); + case 'double': + return double.parse(s); + default: + return num.parse(s); + } + } + + @override + Parser compile(bool isLast) { + return parameter._compile().map((r) => RouteResult( + {parameter.name: getValue(Uri.decodeComponent(r.span!.text))})); + } + + @override + Parser compileNext(Parser p, bool isLast) { + return p.then(parameter._compile()).map((r) { + return (r.value![0] as RouteResult) + ..addAll({ + parameter.name: getValue(Uri.decodeComponent(r.value![1] as String)) + }); + }); + } +} diff --git a/packages/route/lib/src/middleware_pipeline.dart b/packages/route/lib/src/middleware_pipeline.dart new file mode 100644 index 0000000..97da1a3 --- /dev/null +++ b/packages/route/lib/src/middleware_pipeline.dart @@ -0,0 +1,50 @@ +import 'router.dart'; + +/// A chain of arbitrary handlers obtained by routing a path. +class MiddlewarePipeline { + /// All the possible routes that matched the given path. + final Iterable> routingResults; + final List _handlers = []; + + /// An ordered list of every handler delegated to handle this request. + List get handlers { + /* + if (_handlers != null) return _handlers; + final handlers = []; + + for (var result in routingResults) { + handlers.addAll(result.allHandlers); + } + + return _handlers = handlers; + + */ + if (_handlers.isNotEmpty) { + return _handlers; + } + + for (var result in routingResults) { + _handlers.addAll(result.allHandlers); + } + + return _handlers; + } + + MiddlewarePipeline(Iterable> routingResults) + : routingResults = routingResults.toList(); +} + +/// Iterates through a [MiddlewarePipeline]. +class MiddlewarePipelineIterator implements Iterator> { + final MiddlewarePipeline pipeline; + final Iterator> _inner; + + MiddlewarePipelineIterator(this.pipeline) + : _inner = pipeline.routingResults.iterator; + + @override + RoutingResult get current => _inner.current; + + @override + bool moveNext() => _inner.moveNext(); +} diff --git a/packages/route/lib/src/route.dart b/packages/route/lib/src/route.dart new file mode 100644 index 0000000..85baded --- /dev/null +++ b/packages/route/lib/src/route.dart @@ -0,0 +1,99 @@ +part of 'router.dart'; + +/// Represents a virtual location within an application. +class Route { + final String method; + final String path; + final Map> _cache = {}; + final RouteDefinition? _routeDefinition; + final List handlers; + String? name; + Parser? _parser; + + Route(this.path, {required this.method, required this.handlers}) + : _routeDefinition = RouteGrammar.routeDefinition + .parse(SpanScanner(path.replaceAll(_straySlashes, ''))) + .value { + if (_routeDefinition?.segments.isNotEmpty != true) { + _parser = match('').map((r) => RouteResult({})); + } + + /* + var result = RouteGrammar.routeDefinition + .parse(SpanScanner(path.replaceAll(_straySlashes, ''))); + + if (result.value != null) { + + //throw ArgumentError('[Route] Failed to create route for $path'); + _routeDefinition = result.value; + if (_routeDefinition.segments.isEmpty) { + _parser = match('').map((r) => RouteResult({})); + } + } else { + _parser = match('').map((r) => RouteResult({})); + } + */ + } + + factory Route.join(Route a, Route b) { + var start = a.path.replaceAll(_straySlashes, ''); + var end = b.path.replaceAll(_straySlashes, ''); + return Route('$start/$end'.replaceAll(_straySlashes, ''), + method: b.method, handlers: b.handlers); + } + + //List get handlers => _handlers; + + Parser? get parser => _parser ??= _routeDefinition?.compile(); + + @override + String toString() { + return '$method $path => $handlers'; + } + + Route clone() { + return Route(path, method: method, handlers: handlers) + .._cache.addAll(_cache); + } + + String makeUri(Map params) { + var b = StringBuffer(); + var i = 0; + + if (_routeDefinition != null) { + for (var seg in _routeDefinition.segments) { + if (i++ > 0) b.write('/'); + if (seg is ConstantSegment) { + b.write(seg.text); + } else if (seg is ParameterSegment) { + if (!params.containsKey(seg.name)) { + throw ArgumentError('Missing parameter "${seg.name}".'); + } + b.write(params[seg.name]); + } + } + } + + return b.toString(); + } +} + +/// The result of matching an individual route. +class RouteResult { + /// The parsed route parameters. + final Map params; + + /// Optional. An explicit "tail" value to set. + String? get tail => _tail; + + String? _tail; + + RouteResult(this.params, {String? tail}) : _tail = tail; + + void _setTail(String? v) => _tail ??= v; + + /// Adds parameters. + void addAll(Map map) { + params.addAll(map); + } +} diff --git a/packages/route/lib/src/router.dart b/packages/route/lib/src/router.dart new file mode 100644 index 0000000..8585b10 --- /dev/null +++ b/packages/route/lib/src/router.dart @@ -0,0 +1,493 @@ +library platform_route.src.router; + +import 'dart:async'; +import 'package:belatuk_combinator/belatuk_combinator.dart'; +import 'package:string_scanner/string_scanner.dart'; + +import '../string_util.dart'; +import 'routing_exception.dart'; +part 'grammar.dart'; +part 'route.dart'; +part 'routing_result.dart'; +part 'symlink_route.dart'; + +//final RegExp _param = RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?'); +//final RegExp _rgxEnd = RegExp(r'\$+$'); +//final RegExp _rgxStart = RegExp(r'^\^+'); +//final RegExp _rgxStraySlashes = +// RegExp(r'(^((\\+/)|(/))+)|(((\\+/)|(/))+$)'); +//final RegExp _slashDollar = RegExp(r'/+\$'); +final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + +/// An abstraction over complex [Route] trees. Use this instead of the raw API. :) +class Router { + final Map>> _cache = {}; + + //final List<_ChainedRouter> _chained = []; + final List _middleware = []; + final Map> _mounted = {}; + final List> _routes = []; + bool _useCache = false; + + List get middleware => List.unmodifiable(_middleware); + + Map> get mounted => + Map>.unmodifiable(_mounted); + + List> get routes { + return _routes.fold>>([], (out, route) { + if (route is SymlinkRoute) { + var childRoutes = + route.router.routes.fold>>([], (out, r) { + return out + ..add( + route.path.isEmpty ? r : Route.join(route, r), + ); + }); + + return out..addAll(childRoutes); + } else { + return out..add(route); + } + }); + } + + /// Provide a `root` to make this Router revolve around a pre-defined route. + /// Not recommended. + Router(); + + /// Enables the use of a cache to eliminate the overhead of consecutive resolutions of the same path. + void enableCache() { + _useCache = true; + } + + /// 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. + Route addRoute(String method, String path, T handler, + {Iterable middleware = const []}) { + if (_useCache == true) { + throw StateError('Cannot add routes after caching is enabled.'); + } + + // Check if any mounted routers can match this + final handlers = [handler]; + + //middleware ??= []; + + handlers.insertAll(0, middleware); + + final route = Route(path, method: method, handlers: handlers); + _routes.add(route); + return route; + } + + /// Prepends the given [middleware] to any routes created + /// by the resulting router. + /// + /// The resulting router can be chained, too. + ChainedRouter chain(Iterable middleware) { + var piped = ChainedRouter(this, middleware); + var route = SymlinkRoute('/', piped); + _routes.add(route); + return piped; + } + + /// Returns a [Router] with a duplicated version of this tree. + Router clone() { + final router = Router(); + final newMounted = Map>.from(mounted); + + for (var route in routes) { + if (route is! SymlinkRoute) { + router._routes.add(route.clone()); + } else { + final newRouter = route.router.clone(); + newMounted[route.path] = newRouter; + final symlink = SymlinkRoute(route.path, newRouter); + router._routes.add(symlink); + } + } + + return router.._mounted.addAll(newMounted); + } + + /// Creates a visual representation of the route hierarchy and + /// passes it to a callback. If none is provided, `print` is called. + void dumpTree( + {Function(String tree)? callback, + String header = 'Dumping route tree:', + String tab = ' '}) { + final buf = StringBuffer(); + var tabs = 0; + + if (header.isNotEmpty) { + buf.writeln(header); + } + + buf.writeln(''); + + void indent() { + for (var i = 0; i < tabs; i++) { + buf.write(tab); + } + } + + void dumpRouter(Router router) { + indent(); + tabs++; + + for (var route in router.routes) { + indent(); + buf.write('- '); + if (route is! SymlinkRoute) buf.write('${route.method} '); + buf.write(route.path.isNotEmpty ? route.path : '/'); + + if (route is SymlinkRoute) { + buf.writeln(); + dumpRouter(route.router); + } else { + buf.writeln(' => ${route.handlers.length} handler(s)'); + } + } + + tabs--; + } + + dumpRouter(this); + + (callback ?? print)(buf.toString()); + } + + /// Creates a route, and allows you to add child routes to it + /// via a [Router] instance. + /// + /// Returns the created route. + /// You can also register middleware within the router. + SymlinkRoute group(String path, void Function(Router router) callback, + {Iterable middleware = const [], String name = ''}) { + final router = Router().._middleware.addAll(middleware); + callback(router); + return mount(path, router)..name = name; + } + + /// Asynchronous equivalent of [group]. + Future> groupAsync( + String path, FutureOr Function(Router router) callback, + {Iterable middleware = const [], String name = ''}) async { + final router = Router().._middleware.addAll(middleware); + await callback(router); + return mount(path, router)..name = name; + } + + /// Generates a URI string based on the given input. + /// Handy when you have named routes. + /// + /// Each item in `linkParams` should be a [Route], + /// `String` or `Map`. + /// + /// Strings should be route names, namespaces, or paths. + /// Maps should be parameters, which will be filled + /// into the previous route. + /// + /// Paths and segments should correspond to the way + /// you declared them. + /// + /// For example, if you declared a route group on + /// `'users/:id'`, it would not be resolved if you + /// passed `'users'` in [linkParams]. + /// + /// Leading and trailing slashes are automatically + /// removed. + /// + /// Set [absolute] to `true` to insert a forward slash + /// before the generated path. + /// + /// Example: + /// ```dart + /// router.navigate(['users/:id', {'id': '1337'}, 'profile']); + /// ``` + String navigate(Iterable linkParams, {bool absolute = true}) { + final segments = []; + Router search = this; + Route? lastRoute; + + for (final param in linkParams) { + var resolved = false; + + if (param is String) { + // Search by name + for (var route in search.routes) { + if (route.name == param) { + segments.add(route.path.replaceAll(_straySlashes, '')); + lastRoute = route; + + if (route is SymlinkRoute) { + search = route.router; + } + + resolved = true; + break; + } + } + + // Search by path + if (!resolved) { + var scanner = SpanScanner(param.replaceAll(_straySlashes, '')); + for (var route in search.routes) { + var pos = scanner.position; + var parseResult = route.parser?.parse(scanner); + if (parseResult != null) { + if (parseResult.successful && scanner.isDone) { + segments.add(route.path.replaceAll(_straySlashes, '')); + lastRoute = route; + + if (route is SymlinkRoute) { + search = route.router; + } + + resolved = true; + break; + } else { + scanner.position = pos; + } + } else { + scanner.position = pos; + } + } + } + + if (!resolved) { + throw RoutingException( + 'Cannot resolve route for link param "$param".'); + } + } else if (param is Route) { + segments.add(param.path.replaceAll(_straySlashes, '')); + } else if (param is Map) { + if (lastRoute == null) { + throw RoutingException( + 'Maps in link params must be preceded by a Route or String.'); + } else { + segments.removeLast(); + segments.add(lastRoute.makeUri(param).replaceAll(_straySlashes, '')); + } + } else { + throw RoutingException( + 'Link param $param is not Route, String, or Map.'); + } + } + + return absolute + ? '/${segments.join('/').replaceAll(_straySlashes, '')}' + : segments.join('/'); + } + + /// Finds the first [Route] that matches the given path, + /// with the given method. + bool resolve(String absolute, String relative, List> out, + {String method = 'GET', bool strip = true}) { + final cleanRelative = + strip == false ? relative : stripStraySlashes(relative); + var scanner = SpanScanner(cleanRelative); + + bool crawl(Router r) { + var success = false; + + for (var route in r.routes) { + var pos = scanner.position; + + if (route is SymlinkRoute) { + if (route.parser != null) { + var pp = route.parser!; + if (pp.parse(scanner).successful) { + var s = crawl(route.router); + if (s) success = true; + } + } + + scanner.position = pos; + } else if (route.method == '*' || route.method == method) { + var parseResult = route.parser?.parse(scanner); + if (parseResult != null) { + if (parseResult.successful && scanner.isDone) { + var tailResult = parseResult.value?.tail ?? ''; + //print(tailResult); + var result = RoutingResult( + parseResult: parseResult, + params: parseResult.value!.params, + shallowRoute: route, + shallowRouter: this, + tail: tailResult + scanner.rest); + out.add(result); + success = true; + } + } + scanner.position = pos; + } + } + + return success; + } + + return crawl(this); + } + + /// Returns the result of [resolve] with [path] passed as + /// both `absolute` and `relative`. + Iterable> resolveAbsolute(String path, + {String method = 'GET', bool strip = true}) => + resolveAll(path, path, method: method, strip: strip); + + /// Finds every possible [Route] that matches the given path, + /// with the given method. + Iterable> resolveAll(String absolute, String relative, + {String method = 'GET', bool strip = true}) { + if (_useCache == true) { + return _cache.putIfAbsent('$method$absolute', + () => _resolveAll(absolute, relative, method: method, strip: strip)); + } + + return _resolveAll(absolute, relative, method: method, strip: strip); + } + + Iterable> _resolveAll(String absolute, String relative, + {String method = 'GET', bool strip = true}) { + var results = >[]; + resolve(absolute, relative, results, method: method, strip: strip); + + // _printDebug( + // 'Results of $method "/${absolute.replaceAll(_straySlashes, '')}": ${results.map((r) => r.route).toList()}'); + return results; + } + + /// Incorporates another [Router]'s routes into this one's. + SymlinkRoute mount(String path, Router router) { + final route = SymlinkRoute(path, router); + _mounted[route.path] = router; + _routes.add(route); + //route._head = RegExp(route.matcher.pattern.replaceAll(_rgxEnd, '')); + + return route; + } + + /// Adds a route that responds to any request matching the given path. + Route all(String path, T handler, {Iterable middleware = const []}) { + return addRoute('*', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a DELETE request. + Route delete(String path, T handler, {Iterable middleware = const []}) { + return addRoute('DELETE', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a GET request. + Route get(String path, T handler, {Iterable middleware = const []}) { + return addRoute('GET', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a HEAD request. + Route head(String path, T handler, {Iterable middleware = const []}) { + return addRoute('HEAD', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a OPTIONS request. + Route options(String path, T handler, + {Iterable middleware = const {}}) { + return addRoute('OPTIONS', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a POST request. + Route post(String path, T handler, {Iterable middleware = const []}) { + return addRoute('POST', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a PATCH request. + Route patch(String path, T handler, {Iterable middleware = const []}) { + return addRoute('PATCH', path, handler, middleware: middleware); + } + + /// Adds a route that responds to a PUT request. + Route put(String path, T handler, {Iterable middleware = const []}) { + return addRoute('PUT', path, handler, middleware: middleware); + } +} + +class ChainedRouter extends Router { + final List _handlers = []; + Router _root; + + ChainedRouter.empty() : _root = Router(); + + ChainedRouter(this._root, Iterable middleware) { + _handlers.addAll(middleware); + } + + @override + Route addRoute(String method, String path, handler, + {Iterable middleware = const []}) { + var route = super.addRoute(method, path, handler, + middleware: [..._handlers, ...middleware]); + //_root._routes.add(route); + return route; + } + + @override + SymlinkRoute group(String path, void Function(Router router) callback, + {Iterable middleware = const [], String? name}) { + final router = ChainedRouter(_root, [..._handlers, ...middleware]); + callback(router); + return mount(path, router)..name = name; + } + + @override + Future> groupAsync( + String path, FutureOr Function(Router router) callback, + {Iterable middleware = const [], String? name}) async { + final router = ChainedRouter(_root, [..._handlers, ...middleware]); + await callback(router); + return mount(path, router)..name = name; + } + + @override + SymlinkRoute mount(String path, Router router) { + final route = super.mount(path, router); + route.router._middleware.insertAll(0, _handlers); + //_root._routes.add(route); + return route; + } + + @override + ChainedRouter chain(Iterable middleware) { + final piped = ChainedRouter.empty().._root = _root; + piped._handlers.addAll([..._handlers, ...middleware]); + var route = SymlinkRoute('/', piped); + _routes.add(route); + return piped; + } +} + +/// Optimizes a router by condensing all its routes into one level. +Router flatten(Router router) { + var flattened = Router(); + + for (var route in router.routes) { + if (route is SymlinkRoute) { + var base = route.path.replaceAll(_straySlashes, ''); + var child = flatten(route.router); + + for (var route in child.routes) { + var path = route.path.replaceAll(_straySlashes, ''); + var joined = '$base/$path'.replaceAll(_straySlashes, ''); + flattened.addRoute(route.method, joined.replaceAll(_straySlashes, ''), + route.handlers.last, + middleware: + route.handlers.take(route.handlers.length - 1).toList()); + } + } else { + flattened.addRoute(route.method, route.path, route.handlers.last, + middleware: route.handlers.take(route.handlers.length - 1).toList()); + } + } + + return flattened..enableCache(); +} diff --git a/packages/route/lib/src/routing_exception.dart b/packages/route/lib/src/routing_exception.dart new file mode 100644 index 0000000..4dd9b75 --- /dev/null +++ b/packages/route/lib/src/routing_exception.dart @@ -0,0 +1,21 @@ +/// Represents an error in route configuration or navigation. +abstract class RoutingException implements Exception { + factory RoutingException(String message) => _RoutingExceptionImpl(message); + + /// Occurs when trying to resolve the parent of a [Route] without a parent. + factory RoutingException.orphan() => _RoutingExceptionImpl( + "Tried to resolve path '..' on a route that has no parent."); + + /// Occurs when the user attempts to navigate to a non-existent route. + factory RoutingException.noSuchRoute(String path) => _RoutingExceptionImpl( + "Tried to navigate to non-existent route: '$path'."); +} + +class _RoutingExceptionImpl implements RoutingException { + final String message; + + _RoutingExceptionImpl(this.message); + + @override + String toString() => message; +} diff --git a/packages/route/lib/src/routing_result.dart b/packages/route/lib/src/routing_result.dart new file mode 100644 index 0000000..50edfea --- /dev/null +++ b/packages/route/lib/src/routing_result.dart @@ -0,0 +1,95 @@ +part of 'router.dart'; + +/// Represents a complex result of navigating to a path. +class RoutingResult { + /// The parse result that matched the given sub-path. + final ParseResult parseResult; + + /// A nested instance, if a sub-path was matched. + final Iterable> nested; + + /// All route params matching this route on the current sub-path. + final Map params = {}; + + /// The [Route] that answered this sub-path. + /// + /// This is mostly for internal use, and useless in production. + final Route shallowRoute; + + /// The [Router] that answered this sub-path. + /// + /// Only really for internal use. + final Router shallowRouter; + + /// The remainder of the full path that was not matched, and was passed to [nested] routes. + final String tail; + + /// The [RoutingResult] that matched the most specific sub-path. + RoutingResult get deepest { + var search = this; + + while (search.nested.isNotEmpty == true) { + search = search.nested.first; + } + + return search; + } + + /// The most specific route. + Route get route => deepest.shallowRoute; + + /// The most specific router. + Router get router => deepest.shallowRouter; + + /// The handlers at this sub-path. + List get handlers { + return [...shallowRouter.middleware, ...shallowRoute.handlers]; + } + + /// All handlers on this sub-path and its children. + List get allHandlers { + final handlers = []; + + void crawl(RoutingResult result) { + handlers.addAll(result.handlers); + + if (result.nested.isNotEmpty == true) { + for (var r in result.nested) { + crawl(r); + } + } + } + + crawl(this); + + return handlers; + } + + /// All parameters on this sub-path and its children. + Map get allParams { + final params = {}; + + void crawl(RoutingResult result) { + params.addAll(result.params); + + if (result.nested.isNotEmpty == true) { + for (var r in result.nested) { + crawl(r); + } + } + } + + crawl(this); + return params; + } + + RoutingResult( + {required this.parseResult, + Map params = const {}, + this.nested = const Iterable.empty(), + required this.shallowRoute, + required this.shallowRouter, + required this.tail}) { + this.params.addAll(params); + } +} diff --git a/packages/route/lib/src/symlink_route.dart b/packages/route/lib/src/symlink_route.dart new file mode 100644 index 0000000..cf46966 --- /dev/null +++ b/packages/route/lib/src/symlink_route.dart @@ -0,0 +1,8 @@ +part of 'router.dart'; + +/// Placeholder [Route] to serve as a symbolic link +/// to a mounted [Router]. +class SymlinkRoute extends Route { + final Router router; + SymlinkRoute(super.path, this.router) : super(method: 'GET', handlers: []); +} diff --git a/packages/route/lib/string_util.dart b/packages/route/lib/string_util.dart new file mode 100644 index 0000000..d8eb228 --- /dev/null +++ b/packages/route/lib/string_util.dart @@ -0,0 +1,43 @@ +/// Helper functions to performantly transform strings, without `RegExp`. +library angel3_route.string_util; + +/// Removes leading and trailing occurrences of a pattern from a string. +String stripStray(String haystack, String needle) { + int firstSlash; + + if (haystack.startsWith(needle)) { + firstSlash = haystack.indexOf(needle); + if (firstSlash == -1) return haystack; + } else { + firstSlash = -1; + } + + if (firstSlash == haystack.length - 1) { + return haystack.length == 1 ? '' : haystack.substring(0, firstSlash); + } + + // Find last leading index of slash + for (var i = firstSlash + 1; i < haystack.length; i++) { + if (haystack[i] != needle) { + var sub = haystack.substring(i); + + if (!sub.endsWith(needle)) return sub; + + var lastSlash = sub.lastIndexOf(needle); + + for (var j = lastSlash - 1; j >= 0; j--) { + if (sub[j] != needle) { + return sub.substring(0, j + 1); + } + } + + return lastSlash == -1 ? sub : sub.substring(0, lastSlash); + } + } + + return haystack.substring(0, firstSlash); +} + +String stripStraySlashes(String str) => stripStray(str, '/'); + +String stripRegexStraySlashes(String str) => stripStray(str, '\\/'); diff --git a/packages/route/pubspec.yaml b/packages/route/pubspec.yaml new file mode 100644 index 0000000..716766d --- /dev/null +++ b/packages/route/pubspec.yaml @@ -0,0 +1,17 @@ +name: platform_route +version: 9.0.0 +description: A powerful, isomorphic routing library for Dart. It is mainly used in the Angel3 framework, but can be used in Flutter and on the Web. +homepage: https://angel3-framework.web.app/ +repository: https://github.com/dart-backend/angel/tree/master/packages/route +environment: + sdk: '>=3.3.0 <4.0.0' +dependencies: + belatuk_combinator: ^5.1.0 + string_scanner: ^1.2.0 + path: ^1.8.0 +dev_dependencies: + build_runner: ^2.4.0 + build_web_compilers: ^4.0.0 + http: ^1.0.0 + test: ^1.24.0 + lints: ^4.0.0 diff --git a/packages/route/repubspec.yaml b/packages/route/repubspec.yaml new file mode 100644 index 0000000..958364f --- /dev/null +++ b/packages/route/repubspec.yaml @@ -0,0 +1,2 @@ +push_state: + base: push_state/basic.html \ No newline at end of file diff --git a/packages/route/test/chain_nest_test.dart b/packages/route/test/chain_nest_test.dart new file mode 100644 index 0000000..6ac5446 --- /dev/null +++ b/packages/route/test/chain_nest_test.dart @@ -0,0 +1,19 @@ +import 'package:platform_route/platform_route.dart'; +import 'package:test/test.dart'; + +void main() { + var router = Router() + ..chain(['a']).group('/b', (router) { + router.chain(['c']).chain(['d']).group('/e', (router) { + router.get('f', 'g'); + }); + }) + ..dumpTree(); + + test('nested route groups with chain', () { + var r = router.resolveAbsolute('/b/e/f').first.route; + expect(r, isNotNull); + expect(r.handlers, hasLength(4)); + expect(r.handlers, equals(['a', 'c', 'd', 'g'])); + }); +} diff --git a/packages/route/test/navigate_test.dart b/packages/route/test/navigate_test.dart new file mode 100644 index 0000000..135d0d8 --- /dev/null +++ b/packages/route/test/navigate_test.dart @@ -0,0 +1,44 @@ +import 'package:platform_route/platform_route.dart'; +import 'package:test/test.dart'; + +void main() { + final router = Router(); + + router.get('/', 'GET').name = 'root'; + router.get('/user/:id', 'GET'); + router.get('/first/:first/last/:last', 'GET').name = 'full_name'; + + String navigate(params) { + final uri = router.navigate(params as Iterable); + print('Uri: $uri'); + return uri; + } + + router.dumpTree(); + + group('top-level', () { + test('named', () { + expect(navigate(['root']), equals('/')); + }); + + test('params', () { + expect( + navigate([ + 'user/:id', + {'id': 1337} + ]), + equals('/user/1337')); + + expect( + navigate([ + 'full_name', + {'first': 'John', 'last': 'Smith'} + ]), + equals('/first/John/last/Smith')); + }); + + test('root', () { + expect(navigate(['/']), equals('/')); + }); + }); +} diff --git a/packages/route/test/params_test.dart b/packages/route/test/params_test.dart new file mode 100644 index 0000000..f39da1d --- /dev/null +++ b/packages/route/test/params_test.dart @@ -0,0 +1,45 @@ +import 'package:platform_route/platform_route.dart'; +import 'package:test/test.dart'; + +void main() { + final router = Router() + ..get('/hello', '') + ..get('/user/:id', ''); + + router.group('/book/:id', (router) { + router.get('/reviews', ''); + router.get('/readers/:readerId', ''); + }); + + router.mount('/color', Router()..get('/:name/shades', '')); + + setUp(router.dumpTree); + + void expectParams(String path, Map params) { + final p = {}; + final resolved = router.resolveAll(path, path); + print('Resolved $path => ${resolved.map((r) => r.allParams).toList()}'); + for (final result in resolved) { + p.addAll(result.allParams); + } + expect(p, equals(params)); + } + + group('top-level', () { + test('no params', () => expectParams('/hello', {})); + + test('one param', () => expectParams('/user/0', {'id': '0'})); + }); + + group('group', () { + //test('root', () => expectParams('/book/1337', {'id': '1337'})); + test('path', () => expectParams('/book/1337/reviews', {'id': '1337'})); + test( + 'two params', + () => expectParams( + '/book/1337/readers/foo', {'id': '1337', 'readerId': 'foo'})); + }); + + test('mount', + () => expectParams('/color/chartreuse/shades', {'name': 'chartreuse'})); +} diff --git a/packages/route/test/parse_test.dart b/packages/route/test/parse_test.dart new file mode 100644 index 0000000..f976f6c --- /dev/null +++ b/packages/route/test/parse_test.dart @@ -0,0 +1,20 @@ +import 'package:platform_route/platform_route.dart'; +import 'package:test/test.dart'; + +void main() { + var router = Router() + ..get('/int/int:id', '') + ..get('/double/double:id', '') + ..get('/num/num:id', ''); + + num? getId(String path) { + var result = router.resolveAbsolute(path).first; + return result.allParams['id'] as num?; + } + + test('parse', () { + expect(getId('/int/2'), 2); + expect(getId('/double/2.0'), 2.0); + expect(getId('/num/-2.4'), -2.4); + }); +} diff --git a/packages/route/test/root_test.dart b/packages/route/test/root_test.dart new file mode 100644 index 0000000..163b091 --- /dev/null +++ b/packages/route/test/root_test.dart @@ -0,0 +1,15 @@ +import 'package:platform_route/platform_route.dart'; +import 'package:test/test.dart'; + +void main() { + test('resolve / on /', () { + var router = Router() + ..group('/', (router) { + router.group('/', (router) { + router.get('/', 'ok'); + }); + }); + + expect(router.resolveAbsolute('/'), isNotNull); + }); +} diff --git a/packages/route/test/server_test.dart b/packages/route/test/server_test.dart new file mode 100644 index 0000000..e3bb725 --- /dev/null +++ b/packages/route/test/server_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:platform_route/platform_route.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +const List> people = [ + {'name': 'John Smith'} +]; + +void main() { + http.Client? client; + + final router = Router(); + late HttpServer server; + String? url; + + router.get('/', (req, res) { + res.write('Root'); + return false; + }); + + router.get('/hello', (req, res) { + res.write('World'); + return false; + }); + + router.group('/people', (router) { + router.get('/', (req, res) { + res.write(json.encode(people)); + return false; + }); + + router.group('/:id', (router) { + router.get('/', (req, res) { + // In a real application, we would take the param, + // but not here... + res.write(json.encode(people.first)); + return false; + }); + + router.get('/name', (req, res) { + // In a real application, we would take the param, + // but not here... + res.write(json.encode(people.first['name'])); + return false; + }); + }); + }); + + final beatles = Router(); + + beatles.post('/spinal_clacker', (req, res) { + res.write('come '); + return true; + }); + + final yellow = Router() + ..get('/submarine', (req, res) { + res.write('we all live in a'); + return false; + }); + + beatles.group('/big', (router) { + router.mount('/yellow', yellow); + }); + + beatles.all('*', (req, res) { + res.write('together'); + return false; + }); + + router.mount('/beatles', beatles); + + setUp(() async { + client = http.Client(); + + router.dumpTree(); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + url = 'http://${server.address.address}:${server.port}'; + + server.listen((req) async { + final res = req.response; + + // Easy middleware pipeline + final results = + router.resolveAbsolute(req.uri.toString(), method: req.method); + final pipeline = MiddlewarePipeline(results); + + if (pipeline.handlers.isEmpty) { + res + ..statusCode = 404 + ..writeln('404 Not Found'); + } else { + for (final handler in pipeline.handlers) { + if (!((await handler(req, res)) as bool)) break; + } + } + + await res.close(); + }); + }); + + tearDown(() async { + await server.close(force: true); + client!.close(); + client = null; + url = null; + }); + + group('top-level', () { + group('get', () { + test('root', () async { + final res = await client!.get(Uri.parse(url!)); + print('Response: ${res.body}'); + expect(res.body, equals('Root')); + }); + + test('path', () async { + final res = await client!.get(Uri.parse('$url/hello')); + print('Response: ${res.body}'); + expect(res.body, equals('World')); + }); + }); + }); + + group('group', () { + group('top-level', () { + test('root', () async { + final res = await client!.get(Uri.parse('$url/people')); + print('Response: ${res.body}'); + expect(json.decode(res.body), equals(people)); + }); + + group('param', () { + test('root', () async { + final res = await client!.get(Uri.parse('$url/people/0')); + print('Response: ${res.body}'); + expect(json.decode(res.body), equals(people.first)); + }); + + test('path', () async { + final res = await client!.get(Uri.parse('$url/people/0/name')); + print('Response: ${res.body}'); + expect(json.decode(res.body), equals(people.first['name'])); + }); + }); + }); + }); + + group('mount', () { + group('path', () { + test('top-level', () async { + final res = + await client!.post(Uri.parse('$url/beatles/spinal_clacker')); + print('Response: ${res.body}'); + expect(res.body, equals('come together')); + }); + + test('fallback', () async { + final res = await client!.patch(Uri.parse('$url/beatles/muddy_water')); + print('Response: ${res.body}'); + expect(res.body, equals('together')); + }); + + test('fallback', () async { + final res = + await client!.patch(Uri.parse('$url/beatles/spanil_clakcer')); + print('Response: ${res.body}'); + expect(res.body, equals('together')); + }); + }); + + test('deep nested', () async { + final res = + await client!.get(Uri.parse('$url/beatles/big/yellow/submarine')); + print('Response: ${res.body}'); + expect(res.body, equals('we all live in a')); + }); + + group('fallback', () {}); + }); + + group('404', () { + dynamic expect404(r) => r.then((res) { + print('Response (${res.statusCode}): ${res.body}'); + expect(res.statusCode, equals(404)); + }); + + test('path', () async { + await expect404(client!.get(Uri.parse('$url/foo'))); + await expect404(client!.get(Uri.parse('$url/bye'))); + await expect404(client!.get(Uri.parse('$url/people/0/age'))); + await expect404(client!.get(Uri.parse('$url/beatles2'))); + }); + + test('method', () async { + await expect404(client!.head(Uri.parse(url!))); + await expect404(client!.patch(Uri.parse('$url/people'))); + await expect404(client!.post(Uri.parse('$url/people/0'))); + await expect404( + client!.delete(Uri.parse('$url/beatles2/spinal_clacker'))); + }); + }); +} diff --git a/packages/route/test/strip_test.dart b/packages/route/test/strip_test.dart new file mode 100644 index 0000000..42d636c --- /dev/null +++ b/packages/route/test/strip_test.dart @@ -0,0 +1,46 @@ +import 'package:platform_route/string_util.dart'; +import 'package:test/test.dart'; + +void main() { + test('strip leading', () { + var a = '///a'; + var b = stripStraySlashes(a); + print('$a => $b'); + expect(b, 'a'); + }); + + test('strip trailing', () { + var a = 'a///'; + var b = stripStraySlashes(a); + print('$a => $b'); + expect(b, 'a'); + }); + + test('strip both', () { + var a = '///a///'; + var b = stripStraySlashes(a); + print('$a => $b'); + expect(b, 'a'); + }); + + test('intermediate slashes preserved', () { + var a = '///a///b//'; + var b = stripStraySlashes(a); + print('$a => $b'); + expect(b, 'a///b'); + }); + + test('only if starts with', () { + var a = 'd///a///b//'; + var b = stripStraySlashes(a); + print('$a => $b'); + expect(b, 'd///a///b'); + }); + + test('only if ends with', () { + var a = '///a///b//c'; + var b = stripStraySlashes(a); + print('$a => $b'); + expect(b, 'a///b//c'); + }); +} diff --git a/packages/route/test/uri_decode_test.dart b/packages/route/test/uri_decode_test.dart new file mode 100644 index 0000000..58b1637 --- /dev/null +++ b/packages/route/test/uri_decode_test.dart @@ -0,0 +1,18 @@ +import 'package:platform_route/platform_route.dart'; +import 'package:test/test.dart'; + +void main() { + test('uri params decoded', () { + var router = Router()..get('/a/:a/b/:b', ''); + + var encoded = + '/a/${Uri.encodeComponent('<<<')}/b/${Uri.encodeComponent('???')}'; + print(encoded); + var result = router.resolveAbsolute(encoded).first; + print(result.allParams); + expect(result.allParams, { + 'a': '<<<', + 'b': '???', + }); + }); +} diff --git a/packages/route/test/wildcard_test.dart b/packages/route/test/wildcard_test.dart new file mode 100644 index 0000000..cb3f752 --- /dev/null +++ b/packages/route/test/wildcard_test.dart @@ -0,0 +1,46 @@ +import 'package:platform_route/platform_route.dart'; +import 'package:test/test.dart'; + +void main() { + var router = Router(); + router.get('/songs/*/key', 'of life'); + router.get('/isnt/she/*', 'lovely'); + router.all('*', 'stevie'); + + test('match until end if * is last', () { + var result = router.resolveAbsolute('/wonder').first; + expect(result.handlers, ['stevie']); + }); + + test('match if not last', () { + var result = router.resolveAbsolute('/songs/what/key').first; + expect(result.handlers, ['of life']); + }); + + test('match if segments before', () { + var result = + router.resolveAbsolute('/isnt/she/fierce%20harmonica%solo').first; + expect(result.handlers, ['lovely']); + }); + + test('tail explicitly set intermediate', () { + var results = router.resolveAbsolute('/songs/in_the/key'); + var result = results.first; + print(results.map((r) => {r.route.path: r.tail})); + expect(result.tail, 'in_the'); + }); + + test('tail explicitly set at end', () { + var results = router.resolveAbsolute('/isnt/she/epic'); + var result = results.first; + print(results.map((r) => {r.route.path: r.tail})); + expect(result.tail, 'epic'); + }); + + test('tail with trailing', () { + var results = router.resolveAbsolute('/isnt/she/epic/fail'); + var result = results.first; + print(results.map((r) => {r.route.path: r.tail})); + expect(result.tail, 'epic/fail'); + }); +} diff --git a/packages/route/web/hash/basic.dart b/packages/route/web/hash/basic.dart new file mode 100644 index 0000000..39e4eb4 --- /dev/null +++ b/packages/route/web/hash/basic.dart @@ -0,0 +1,4 @@ +import 'package:platform_route/browser.dart'; +import '../shared/basic.dart'; + +void main() => basic(BrowserRouter(hash: true)); diff --git a/packages/route/web/hash/basic.html b/packages/route/web/hash/basic.html new file mode 100644 index 0000000..ea7fbaf --- /dev/null +++ b/packages/route/web/hash/basic.html @@ -0,0 +1,30 @@ + + + + + + Hash Router + + + + +

No Active Route

+Handler Sequence: +
    +
  • (empty)
  • +
+ + + \ No newline at end of file diff --git a/packages/route/web/index.html b/packages/route/web/index.html new file mode 100644 index 0000000..4806c9a --- /dev/null +++ b/packages/route/web/index.html @@ -0,0 +1,15 @@ + + + + + + Angel Route Samples + + +

Angel Route Samples

+ + + \ No newline at end of file diff --git a/packages/route/web/push_state/basic.dart b/packages/route/web/push_state/basic.dart new file mode 100644 index 0000000..06483ca --- /dev/null +++ b/packages/route/web/push_state/basic.dart @@ -0,0 +1,4 @@ +import 'package:platform_route/browser.dart'; +import '../shared/basic.dart'; + +void main() => basic(BrowserRouter()); diff --git a/packages/route/web/push_state/basic.html b/packages/route/web/push_state/basic.html new file mode 100644 index 0000000..953bc5a --- /dev/null +++ b/packages/route/web/push_state/basic.html @@ -0,0 +1,31 @@ + + + + + + + Push State Router + + + + +

No Active Route

+Handler Sequence: +
    +
  • (empty)
  • +
+ + + \ No newline at end of file diff --git a/packages/route/web/shared/basic.dart b/packages/route/web/shared/basic.dart new file mode 100644 index 0000000..a64ee1d --- /dev/null +++ b/packages/route/web/shared/basic.dart @@ -0,0 +1,42 @@ +import 'dart:html'; +import 'package:platform_route/browser.dart'; + +void basic(BrowserRouter router) { + final $h1 = window.document.querySelector('h1'); + final $ul = window.document.getElementById('handlers'); + + router.onResolve.listen((result) { + final route = result.route; + + // TODO: Relook at this logic + //if (route == null) { + // $h1!.text = 'No Active Route'; + // $ul!.children + // ..clear() + // ..add(LIElement()..text = '(empty)'); + //} else { + if ($h1 != null && $ul != null) { + $h1.text = 'Active Route: ${route.name}'; + $ul.children + ..clear() + ..addAll(result.allHandlers + .map((handler) => LIElement()..text = handler.toString())); + } else { + print('No active Route'); + } + //} + }); + + router.get('a', 'a handler'); + + router.group('b', (router) { + router.get('a', 'b/a handler').name = 'b/a'; + router.get('b', 'b/b handler', middleware: ['b/b middleware']).name = 'b/b'; + }, middleware: ['b middleware']); + + router.get('c', 'c handler'); + + router + ..dumpTree() + ..listen(); +}