From 6ad3141fb14811f226ddae70b15ed69c4d0155ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 15 May 2025 07:52:28 +0200 Subject: [PATCH 01/10] feat(NormalizedPath): Optimize subPath for empty and equal case. --- lib/src/router/normalized_path.dart | 16 ++++++++++++++-- pubspec.yaml | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/src/router/normalized_path.dart b/lib/src/router/normalized_path.dart index ce64eaea..2abdd2b3 100644 --- a/lib/src/router/normalized_path.dart +++ b/lib/src/router/normalized_path.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + import 'lru_cache.dart'; /// Represents a URL path that has been normalized. @@ -9,6 +11,7 @@ import 'lru_cache.dart'; /// /// Instances are interned using an LRU cache for efficiency, meaning identical /// normalized paths will often share the same object instance. +@immutable class NormalizedPath { /// Cache of interned instances static var interned = LruCache(10000); @@ -20,6 +23,9 @@ class NormalizedPath { /// Private constructor to create an instance with already normalized segments. NormalizedPath._(this.segments); + /// Empty normalized path instance. + static NormalizedPath empty = NormalizedPath._(const []); + /// Creates a [NormalizedPath] from a given [path] string. /// /// The provided [path] will be normalized by resolving `.` and `..` segments @@ -60,8 +66,14 @@ class NormalizedPath { /// /// The [start] parameter specifies the starting segment index (inclusive). /// The optional [end] parameter specifies the ending segment index (exclusive). - NormalizedPath subPath(final int start, [final int? end]) => - NormalizedPath._(segments.sublist(start, end)); + NormalizedPath subPath(final int start, [int? end]) { + end ??= length; + if (start == end) return NormalizedPath.empty; + if (start == 0 && end == length) { + return this; // since NormalizedPath is immutable + } + return NormalizedPath._(segments.sublist(start, end)); + } /// The number of segments in this path int get length => segments.length; diff --git a/pubspec.yaml b/pubspec.yaml index 69a3a907..400ec888 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: collection: ^1.18.0 convert: ^3.1.1 http_parser: ^4.0.2 + meta: ^1.16.0 mime: ">=1.0.6 <3.0.0" path: ^1.8.3 stack_trace: ^1.10.0 @@ -31,6 +32,5 @@ dev_dependencies: # our direct dependencies indicate. file: ^7.0.0 # ignore: sort_pub_dependencies frontend_server_client: ^4.0.0 - meta: ^1.9.1 pub_semver: ^2.1.4 watcher: ^1.1.0 From 510fb02302d99468414207c6cb689b193d750ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 15 May 2025 07:52:28 +0200 Subject: [PATCH 02/10] feat(PathTrie): Add support for wildcard /*/ and tail matching /** --- lib/src/router/path_trie.dart | 137 +++++++++++++++++++++++++++------- lib/src/router/router.dart | 16 +++- 2 files changed, 122 insertions(+), 31 deletions(-) diff --git a/lib/src/router/path_trie.dart b/lib/src/router/path_trie.dart index dd26885a..c1ff6b0c 100644 --- a/lib/src/router/path_trie.dart +++ b/lib/src/router/path_trie.dart @@ -1,15 +1,26 @@ import 'normalized_path.dart'; +typedef Parameters = Map; + /// Represents the result of a route lookup. final class LookupResult { /// The value associated with the matched route. final T value; /// A map of parameter names to their extracted values from the path. - final Map parameters; + final Parameters parameters; + + /// The normalized path that was matched. + final NormalizedPath matched; + + /// If a match, does not consume the full path, then stores the [remaining] + /// + /// This can only happen with path that ends with a tail-match /:: or /::name, + /// otherwise it will be empty. + final NormalizedPath remaining; /// Creates a [LookupResult] with the given [value] and [parameters]. - const LookupResult(this.value, this.parameters); + const LookupResult(this.value, this.parameters, this.matched, this.remaining); } /// A node within the path trie. @@ -18,10 +29,7 @@ final class _TrieNode { final Map> children = {}; /// Parameter definition associated with this node, if any. - /// - /// Stores the parameter name and the child node that represents the - /// parameterized path segment. - _Parameter? parameter; + _DynamicSegment? dynamicSegment; /// The value associated with the route ending at this node. /// @@ -29,7 +37,21 @@ final class _TrieNode { T? value; } -typedef _Parameter = ({_TrieNode node, String name}); +sealed class _DynamicSegment { + final node = _TrieNode(); +} + +/// Stores the parameter [name] and the child [node] that represents the +/// parameterized path segment. +final class _Parameter extends _DynamicSegment { + final String name; + + _Parameter(this.name); +} + +final class _Wildcard extends _DynamicSegment {} + +final class _Tail extends _DynamicSegment {} /// A Trie (prefix tree) data structure optimized for matching URL paths. /// @@ -152,10 +174,17 @@ final class PathTrie { for (final segment in segments) { var nextNode = currentNode.children[segment]; - if (nextNode == null && segment.startsWith(':')) { - final parameter = currentNode.parameter; - if (parameter != null && parameter.name == segment.substring(1)) { - nextNode = parameter.node; + if (nextNode == null) { + if (segment == '**') { + // Handle tail segment + } else if (segment == '*') { + // Handle wildcard segment + } else if (segment.startsWith(':')) { + // Handle parameter segment + final parameter = currentNode.dynamicSegment as _Parameter?; + if (parameter != null && parameter.name == segment.substring(1)) { + nextNode = parameter.node; + } } } if (nextNode == null) return null; // early exit @@ -169,17 +198,51 @@ final class PathTrie { final segments = normalizedPath.segments; _TrieNode currentNode = _root; - for (final segment in segments) { - if (segment.startsWith(':')) { + // Helper function + @pragma('vm:prefer-inline') + void isA>( + final _DynamicSegment? dynamicSegment) { + if (dynamicSegment != null && dynamicSegment is! U) { + throw ArgumentError(); + } + } + + for (int i = 0; i < segments.length; i++) { + final segment = segments[i]; + final dynamicSegment = currentNode.dynamicSegment; + + if (segment.startsWith('**')) { + // Handle tail segment + if (segment != '**') { + throw ArgumentError.value(normalizedPath, 'normalizedPath', + '"$segment" not allowed. Starts with "**"'); + } + if (i < segments.length - 1) { + throw ArgumentError.value(normalizedPath, 'normalizedPath', + 'Tail segment (**) must be the last segment in the path definition.'); + } + isA<_Tail>(dynamicSegment); + currentNode = (currentNode.dynamicSegment ??= _Tail()).node; + } else if (segment.startsWith('*')) { + // Handle wildcard segment + if (segment != '*') { + throw ArgumentError.value(normalizedPath, 'normalizedPath', + '"$segment" not allowed. Starts with "*"'); + } + isA<_Wildcard>(dynamicSegment); + currentNode = (currentNode.dynamicSegment ??= _Wildcard()).node; + } else if (segment.startsWith(':')) { + // Handle parameter segment + isA<_Parameter>(dynamicSegment); final paramName = segment.substring(1).trim(); if (paramName.isEmpty) { throw ArgumentError.value(normalizedPath, 'normalizedPath', 'Parameter name cannot be empty'); } // Ensure parameter child exists and handle name conflicts - var parameter = currentNode.parameter; + var parameter = dynamicSegment as _Parameter?; if (parameter == null) { - parameter = (node: _TrieNode(), name: paramName); + parameter = _Parameter(paramName); } else if (parameter.name != paramName) { // Throw an error if a different parameter name already exists at this level. throw ArgumentError( @@ -190,7 +253,7 @@ final class PathTrie { 'normalizedPath', ); } - currentNode.parameter = parameter; + currentNode.dynamicSegment = parameter; currentNode = parameter.node; } else { // Handle literal segment @@ -214,7 +277,7 @@ final class PathTrie { /// /// Throws an [ArgumentError] if: /// - The node at [normalizedPath] has a value, and the root node of [trie] has as well. - /// - Both nodes has an associated parameter. + /// - Both nodes has an associated dynamic segment. /// - There are overlapping children between the nodes. void attach(final NormalizedPath normalizedPath, final PathTrie trie) { final node = trie._root; @@ -224,7 +287,7 @@ final class PathTrie { throw ArgumentError('Conflicting values'); } - if (currentNode.parameter != null && node.parameter != null) { + if (currentNode.dynamicSegment != null && node.dynamicSegment != null) { throw ArgumentError('Conflicting parameters'); } @@ -236,7 +299,7 @@ final class PathTrie { // No conflicts so safe to update currentNode.value ??= node.value; - currentNode.parameter ??= node.parameter; + currentNode.dynamicSegment ??= node.dynamicSegment; currentNode.children.addAll(node.children); trie._root = currentNode; } @@ -253,25 +316,41 @@ final class PathTrie { _TrieNode currentNode = _root; final parameters = {}; - for (final segment in segments) { + int i = 0; + for (; i < segments.length; i++) { + final segment = segments[i]; final child = currentNode.children[segment]; if (child != null) { // Prioritize literal match currentNode = child; } else { - final parameter = currentNode.parameter; - if (parameter != null) { - // Match parameter + final dynamicSegment = currentNode.dynamicSegment; + if (dynamicSegment == null) return null; // no match + currentNode = dynamicSegment.node; + if (dynamicSegment case final _Parameter parameter) { parameters[Symbol(parameter.name)] = segment; - currentNode = parameter.node; - } else { - // No match - return null; } + if (dynamicSegment is _Tail) break; // possible early match + } + } + + T? value = currentNode.value; + final matchedPath = normalizedPath.subPath(0, i); + final remainingPath = normalizedPath.subPath(i); + + // If no value found after iterating through all segments, check if + // currentNode has a tail. If so proceed one more step. This handles cases + // like /archive/** matching /archive, where remainingPath would be empty. + if (value == null && i == segments.length) { + final dynamicSegment = currentNode.dynamicSegment; + if (dynamicSegment is _Tail) { + currentNode = dynamicSegment.node; + value = currentNode.value; } } - final value = currentNode.value; - return value != null ? LookupResult(value, parameters) : null; + return value != null + ? LookupResult(value, parameters, matchedPath, remainingPath) + : null; } } diff --git a/lib/src/router/router.dart b/lib/src/router/router.dart index 9b6c7e0c..345e4635 100644 --- a/lib/src/router/router.dart +++ b/lib/src/router/router.dart @@ -96,7 +96,14 @@ final class Router { // Try static cache first final value = _staticCache[normalizedPath]?.find(method); - if (value != null) return LookupResult(value, const {}); + if (value != null) { + return LookupResult( + value, + const {}, + normalizedPath, + NormalizedPath.empty, + ); + } // Fall back to trie final entry = _allRoutes.lookup(normalizedPath); @@ -110,7 +117,12 @@ final class Router { _staticCache[normalizedPath] = entry.value; } - return LookupResult(route, entry.parameters); + return LookupResult( + route, + entry.parameters, + entry.matched, + entry.remaining, + ); } } From 2571dfd2d20c732fee4c8b03413b3898173edca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 15 May 2025 17:00:10 +0200 Subject: [PATCH 03/10] test: Add test for new dynamic segment types (/*/ and /**) --- test/router/path_trie_tail_test.dart | 265 +++++++++++++++++++++++ test/router/path_trie_wildcard_test.dart | 154 +++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 test/router/path_trie_tail_test.dart create mode 100644 test/router/path_trie_wildcard_test.dart diff --git a/test/router/path_trie_tail_test.dart b/test/router/path_trie_tail_test.dart new file mode 100644 index 00000000..1e0ecd52 --- /dev/null +++ b/test/router/path_trie_tail_test.dart @@ -0,0 +1,265 @@ +import 'package:relic/src/router/normalized_path.dart'; +import 'package:relic/src/router/path_trie.dart'; +import 'package:test/test.dart'; + +void main() { + group('PathTrie Tail (**) Matching', () { + late PathTrie trie; + + setUp(() { + trie = PathTrie(); + }); + + test( + 'Given a trie with path /static/**, ' + 'when /static/css/style.css is looked up, ' + 'then it matches with correct value and remaining path', () { + trie.add(NormalizedPath('/static/**'), 1); + final result = trie.lookup(NormalizedPath('/static/css/style.css')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/static'); + expect(result.remaining.path, '/css/style.css'); + }); + + test( + 'Given a trie with path /files/**, ' + 'when /files/a/b/c/doc.txt (multiple segments for tail) is looked up, ' + 'then it matches with correct value and remaining path', () { + trie.add(NormalizedPath('/files/**'), 1); + final result = trie.lookup(NormalizedPath('/files/a/b/c/doc.txt')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/files'); + expect(result.remaining.path, '/a/b/c/doc.txt'); + }); + + test( + 'Given a trie with path /archive/**, ' + 'when /archive/ (ends where tail starts) is looked up, ' + 'then it matches with an empty remaining path', () { + trie.add(NormalizedPath('/archive/**'), 1); + final result = trie.lookup(NormalizedPath('/archive/')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/archive'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /exact/**, ' + 'when /exact (no trailing slash, ends where tail starts) is looked up, ' + 'then it matches with an empty remaining path', () { + trie.add(NormalizedPath('/exact/**'), 1); + final result = trie.lookup(NormalizedPath('/exact')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/exact'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /**, ' + 'when /any/path/anywhere is looked up, ' + 'then it matches with an empty matched path and correct remaining path', + () { + trie.add(NormalizedPath('/**'), 1); + final result = trie.lookup(NormalizedPath('/any/path/anywhere')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.segments, isEmpty); + expect(result.remaining.path, '/any/path/anywhere'); + }); + + group('Root path (/) matching with root tail (/**) interactions', () { + setUp(() { + // Ensures a fresh trie for each scenario in this group + trie = PathTrie(); + }); + + test( + 'Given a trie with only path /** defined, ' + 'when the root path / is looked up,' + 'then /** matches with its value and empty matched/remaining paths', + () { + trie.add(NormalizedPath('/**'), 1); + final result = trie.lookup(NormalizedPath('/')); + expect(result, isNotNull, + reason: 'Lookup for / should find /** if / has no value'); + expect(result!.value, 1); + expect(result.matched.segments, isEmpty, + reason: 'Matched path for /** lookup of / should be empty'); + expect(result.remaining.segments, isEmpty, + reason: 'Remaining path for /** lookup of / should be empty'); + }); + + test( + 'Given a trie with only path / defined, ' + 'when the root path / is looked up, ' + 'then / matches with its value and empty matched/remaining paths', + () { + trie.add(NormalizedPath('/'), 2); + final result = trie.lookup(NormalizedPath('/')); + expect(result, isNotNull); + expect(result!.value, 2); + expect(result.matched.segments, isEmpty); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path / and path /** defined, ' + 'when the root path / is looked up, ' + 'then the literal path / takes precedence with its value', () { + trie.add(NormalizedPath('/'), 2); + trie.add(NormalizedPath('/**'), 3); // /** has a different value + + final result = trie.lookup(NormalizedPath('/')); + expect(result, isNotNull, + reason: 'Lookup for / should prefer value on / over /**'); + expect(result!.value, 2, reason: 'Value from / should be preferred'); + expect(result.matched.segments, isEmpty); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path / and path /** defined, ' + 'when a sub-path like /some/path is looked up, ' + 'then path /** matches with its value and correct remaining path', + () { + trie.add(NormalizedPath('/'), 2); + trie.add(NormalizedPath('/**'), 3); + + final result = trie.lookup(NormalizedPath('/some/path')); + expect(result, isNotNull, + reason: '/** should still match longer paths'); + expect(result!.value, 3, + reason: 'Value from /** should match longer paths'); + expect(result.matched.segments, isEmpty); + expect(result.remaining.path, '/some/path'); + }); + }); + + test( + 'Given a trie with /assets/js/app.js and /assets/**, ' + 'when paths are looked up, ' + 'then literal is preferred and tail matches remaining', () { + trie.add(NormalizedPath('/assets/js/app.js'), 1); + trie.add(NormalizedPath('/assets/**'), 2); + + final literalResult = trie.lookup(NormalizedPath('/assets/js/app.js')); + expect(literalResult, isNotNull); + expect(literalResult!.value, 1); + expect(literalResult.matched.path, '/assets/js/app.js'); + expect(literalResult.remaining.segments, isEmpty); + + final tailResult = trie.lookup(NormalizedPath('/assets/img/logo.png')); + expect(tailResult, isNotNull); + expect(tailResult!.value, 2); + expect(tailResult.matched.path, '/assets'); + expect(tailResult.remaining.path, '/img/logo.png'); + }); + + test( + 'Given a trie with /foo/bar/** and /foo/**, ' + 'when paths are looked up, ' + 'then the more specific /foo/bar/** is chosen over /foo/**', () { + trie.add(NormalizedPath('/foo/bar/**'), 1); + trie.add(NormalizedPath('/foo/**'), 2); + + final resSpecific = trie.lookup(NormalizedPath('/foo/bar/baz/qux')); + expect(resSpecific, isNotNull); + expect(resSpecific!.value, 1); + expect(resSpecific.matched.path, '/foo/bar'); + expect(resSpecific.remaining.path, '/baz/qux'); + + final resGeneral = trie.lookup(NormalizedPath('/foo/other/path')); + expect(resGeneral, isNotNull); + expect(resGeneral!.value, 2); + expect(resGeneral.matched.path, '/foo'); + expect(resGeneral.remaining.path, '/other/path'); + }); + + test( + 'Given a trie with path /user/:id/files/**, ' + 'when /user/42/files/docs/report.pdf is looked up, ' + 'then it matches with correct parameter and remaining path', () { + trie.add(NormalizedPath('/user/:id/files/**'), 1); + final result = + trie.lookup(NormalizedPath('/user/42/files/docs/report.pdf')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, equals({#id: '42'})); + expect(result.matched.path, '/user/42/files'); + expect(result.remaining.path, '/docs/report.pdf'); + }); + + test( + 'Given a trie with path /data/export/**, ' + 'when /data (shorter than prefix) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/data/export/**'), 1); + expect(trie.lookup(NormalizedPath('/data')), isNull); + }); + + test( + 'Given an empty trie, ' + 'when adding a path like /**foo (tail not a full segment), ' + 'then an ArgumentError is thrown', () { + expect(() => trie.add(NormalizedPath('/downloads/**foo'), 1), + throwsArgumentError); + }); + + group('Tail and Other Segment interaction validation', () { + test( + 'Given a trie with /test/**, ' + 'when adding /test/:id (parameter after tail at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/**'), 1); + expect(() => trie.add(NormalizedPath('/test/:id'), 2), + throwsArgumentError); + }); + + test( + 'Given a trie with /test/:id, ' + 'when adding /test/** (tail after parameter at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/:id'), 1); + expect( + () => trie.add(NormalizedPath('/test/**'), 2), throwsArgumentError); + }); + + test( + 'Given a trie with /test/**, ' + 'when adding /test/* (wildcard after tail at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/**'), 1); + expect( + () => trie.add(NormalizedPath('/test/*'), 2), throwsArgumentError); + }); + + test( + 'Given a trie with /test/*, ' + 'when adding /test/** (tail after wildcard at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/*'), 1); + expect( + () => trie.add(NormalizedPath('/test/**'), 2), throwsArgumentError); + }); + + test( + 'Given an empty trie, ' + 'when adding a path /a/**/b/c (tail /** not as the last segment), ' + 'then an ArgumentError is thrown', () { + expect( + () => trie.add(NormalizedPath('/a/**/b/c'), 1), throwsArgumentError, + reason: + 'Tail segment /** must be the last segment in a path definition.'); + }); + }); + }); +} diff --git a/test/router/path_trie_wildcard_test.dart b/test/router/path_trie_wildcard_test.dart new file mode 100644 index 00000000..9ae31bac --- /dev/null +++ b/test/router/path_trie_wildcard_test.dart @@ -0,0 +1,154 @@ +import 'package:relic/src/router/normalized_path.dart'; +import 'package:relic/src/router/path_trie.dart'; +import 'package:test/test.dart'; + +void main() { + group('PathTrie Wildcard (*) Matching', () { + late PathTrie trie; + + setUp(() { + trie = PathTrie(); + }); + + test( + 'Given a trie with path /users/*/profile, ' + 'when /users/123/profile is looked up, ' + 'then it matches with correct value and paths', () { + trie.add(NormalizedPath('/users/*/profile'), 1); + final result = trie.lookup(NormalizedPath('/users/123/profile')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/users/123/profile'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /*/resource, ' + 'when /any/resource is looked up, ' + 'then it matches with correct value and paths', () { + trie.add(NormalizedPath('/*/resource'), 1); + final result = trie.lookup(NormalizedPath('/any/resource')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/any/resource'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /files/*, ' + 'when /files/image.jpg is looked up, ' + 'then it matches with correct value and paths', () { + trie.add(NormalizedPath('/files/*'), 1); + final result = trie.lookup(NormalizedPath('/files/image.jpg')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/files/image.jpg'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /a/*/c/*, ' + 'when /a/b/c/d is looked up, ' + 'then it matches with correct value and paths', () { + trie.add(NormalizedPath('/a/*/c/*'), 1); + final result = trie.lookup(NormalizedPath('/a/b/c/d')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/a/b/c/d'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /a/*/b, ' + 'when /a/b (fewer segments) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/a/*/b'), 1); + expect(trie.lookup(NormalizedPath('/a/b')), isNull); + }); + + test( + 'Given a trie with /data/specific and /data/*, ' + 'when they are looked up, ' + 'then literal /data/specific is preferred over /data/*, and /data/* matches other segments', + () { + trie.add(NormalizedPath('/data/specific'), 1); + trie.add(NormalizedPath('/data/*'), 2); + final result = trie.lookup(NormalizedPath('/data/specific')); + expect(result, isNotNull); + expect(result!.value, 1); + + final wildResult = trie.lookup(NormalizedPath('/data/general')); + expect(wildResult, isNotNull); + expect(wildResult!.value, 2); + }); + + test( + 'Given a trie with path /assets/*, ' + 'when /assets/img/logo.png (wildcard part spans multiple segments) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/assets/*'), 1); + expect(trie.lookup(NormalizedPath('/assets/img/logo.png')), isNull); + }); + + test( + 'Given a trie with path /api/:version/data/*, ' + 'when /api/v1/data/users is looked up, ' + 'then it matches with correct value, parameter, and paths', () { + trie.add(NormalizedPath('/api/:version/data/*'), 1); + final result = trie.lookup(NormalizedPath('/api/v1/data/users')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, equals({#version: 'v1'})); + expect(result.matched.path, '/api/v1/data/users'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /a/b/*/d, ' + 'when /a/b/c (shorter) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/a/b/*/d'), 1); + expect(trie.lookup(NormalizedPath('/a/b/c')), isNull); + }); + + test( + 'Given a trie with path /a/b/* (no tail), ' + 'when /a/b/c/d (longer) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/a/b/*'), 1); + expect(trie.lookup(NormalizedPath('/a/b/c/d')), isNull); + }); + + test( + 'Given an empty trie, ' + 'when adding a path like /*foo/bar (wildcard not a full segment), ' + 'then an ArgumentError is thrown', () { + expect( + () => trie.add(NormalizedPath('/*foo/bar'), 1), throwsArgumentError); + }); + + group('Wildcard and Parameter interaction validation', () { + test( + 'Given a trie with /test/*, ' + 'when adding /test/:id (parameter after wildcard at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/*'), 1); + expect(() => trie.add(NormalizedPath('/test/:id'), 2), + throwsArgumentError); + }); + + test( + 'Given a trie with /test/:id, ' + 'when adding /test/* (wildcard after parameter at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/:id'), 1); + expect( + () => trie.add(NormalizedPath('/test/*'), 2), throwsArgumentError); + }); + }); + }); +} From f1f049052286033adc35958a09265917bb85c07f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 16 May 2025 08:50:01 +0200 Subject: [PATCH 04/10] test: Extend crud tests for /*/ and /** --- test/router/path_trie_crud_test.dart | 121 +++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/test/router/path_trie_crud_test.dart b/test/router/path_trie_crud_test.dart index 86b95526..3db7c670 100644 --- a/test/router/path_trie_crud_test.dart +++ b/test/router/path_trie_crud_test.dart @@ -175,6 +175,61 @@ void main() { expect(() => trie.update(pathDefinition, 1), throwsArgumentError); expect(trie.lookup(NormalizedPath('/articles/any')), isNull); }); + + test( + 'Given a trie with an existing wildcard path /data/* and value, ' + 'when update is called for /data/* with a new value, ' + 'then lookup for a matching path returns the new value', () { + final pathDefinition = NormalizedPath('/data/*'); + trie.add(pathDefinition, 1); + + trie.update(pathDefinition, 2); + + // Verify by looking up a path that would match the wildcard definition + final result = trie.lookup(NormalizedPath('/data/something')); + expect(result, isNotNull); + expect(result!.value, equals(2)); + expect( + result.parameters, isEmpty); // Wildcards don't produce parameters + expect(result.matched.toString(), '/data/something'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie, ' + 'when update is called for a wildcard path /data/* that does not exist as a defined route, ' + 'then an ArgumentError is thrown', () { + final pathDefinition = NormalizedPath('/data/*'); + expect(() => trie.update(pathDefinition, 1), throwsArgumentError); + expect(trie.lookup(NormalizedPath('/data/anything')), isNull); + }); + + test( + 'Given a trie with an existing tail path /files/** and value, ' + 'when update is called for /files/** with a new value, ' + 'then lookup for a matching path returns the new value', () { + final pathDefinition = NormalizedPath('/files/**'); + trie.add(pathDefinition, 1); + + trie.update(pathDefinition, 2); + + // Verify by looking up a path that would match the tail definition + final result = trie.lookup(NormalizedPath('/files/a/b.txt')); + expect(result, isNotNull); + expect(result!.value, equals(2)); + expect(result.parameters, isEmpty); + expect(result.matched.toString(), '/files'); + expect(result.remaining.toString(), '/a/b.txt'); + }); + + test( + 'Given a trie, ' + 'when update is called for a tail path /files/** that does not exist as a defined route, ' + 'then an ArgumentError is thrown', () { + final pathDefinition = NormalizedPath('/files/**'); + expect(() => trie.update(pathDefinition, 1), throwsArgumentError); + expect(trie.lookup(NormalizedPath('/files/anything/else')), isNull); + }); }); group('remove', () { @@ -300,6 +355,72 @@ void main() { expect(trie.lookup(pathABC), isNull); expect(trie.lookup(pathABD)?.value, 2); }); + + test( + 'Given a trie with an existing wildcard path /data/* and value, ' + 'when remove is called for /data/*, ' + 'then the value is removed and lookup for matching paths returns null', + () { + final pathDefinition = NormalizedPath('/data/*'); + trie.add(pathDefinition, 10); + trie.add(NormalizedPath('/data/fixed'), 20); // Sibling literal + + final removedValue = trie.remove(pathDefinition); + + expect(removedValue, equals(10)); + expect(trie.lookup(NormalizedPath('/data/any')), isNull, + reason: 'Wildcard path should be removed.'); + expect(trie.lookup(NormalizedPath('/data/fixed'))?.value, 20, + reason: 'Sibling literal path should be unaffected.'); + }); + + test( + 'Given a trie, ' + 'when remove is called for a wildcard path /data/* that does not exist as a defined route, ' + 'then null is returned and no other paths are affected', () { + final pathDefinition = NormalizedPath('/data/*'); + trie.add( + NormalizedPath('/other/*'), 1); // Add a different wildcard path + + final removedValue = trie.remove(pathDefinition); + + expect(removedValue, isNull); + expect(trie.lookup(NormalizedPath('/other/something'))?.value, 1); + }); + + test( + 'Given a trie with an existing tail path /files/** and value, ' + 'when remove is called for /files/**, ' + 'then the value is removed and lookup for matching paths returns null', + () { + final pathDefinition = NormalizedPath('/files/**'); + trie.add(pathDefinition, 30); + trie.add(NormalizedPath('/files/specific/file.txt'), + 40); // More specific child + + final removedValue = trie.remove(pathDefinition); + + expect(removedValue, equals(30)); + expect(trie.lookup(NormalizedPath('/files/a/b/c')), isNull, + reason: 'Tail path should be removed.'); + final specificLookup = + trie.lookup(NormalizedPath('/files/specific/file.txt')); + expect(specificLookup?.value, 40, + reason: + 'More specific path should ideally remain if structured correctly.'); + }); + + test( + 'Given a trie, ' + 'when remove is called for a tail path /files/** that does not exist as a defined route, ' + 'then null is returned and no other paths are affected', () { + final pathDefinition = NormalizedPath('/files/**'); + trie.add(NormalizedPath('/archive/**'), 1); // Add a different tail path + + final removedValue = trie.remove(pathDefinition); + expect(removedValue, isNull); + expect(trie.lookup(NormalizedPath('/archive/some/file'))?.value, 1); + }); }); }); } From d7fe6cc800bed37e22bed1828507c4fbc4d8310f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 16 May 2025 08:38:52 +0200 Subject: [PATCH 05/10] fix: _find not updated to handle /*/ and /** --- lib/src/router/path_trie.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/src/router/path_trie.dart b/lib/src/router/path_trie.dart index c1ff6b0c..283509d8 100644 --- a/lib/src/router/path_trie.dart +++ b/lib/src/router/path_trie.dart @@ -172,16 +172,29 @@ final class PathTrie { final segments = normalizedPath.segments; _TrieNode currentNode = _root; + // Helper function + @pragma('vm:prefer-inline') + _TrieNode? nextIf>( + final _DynamicSegment? dynamicSegment) { + if (dynamicSegment != null && dynamicSegment is U) { + return dynamicSegment.node; + } + return null; + } + for (final segment in segments) { var nextNode = currentNode.children[segment]; if (nextNode == null) { + final dynamicSegment = currentNode.dynamicSegment; if (segment == '**') { // Handle tail segment + nextNode = nextIf<_Tail>(dynamicSegment); } else if (segment == '*') { // Handle wildcard segment + nextNode = nextIf<_Wildcard>(dynamicSegment); } else if (segment.startsWith(':')) { // Handle parameter segment - final parameter = currentNode.dynamicSegment as _Parameter?; + final parameter = dynamicSegment as _Parameter?; if (parameter != null && parameter.name == segment.substring(1)) { nextNode = parameter.node; } From 65f9e5f53b1a06f21c2bba158affbfd1857142ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 16 May 2025 13:58:19 +0200 Subject: [PATCH 06/10] refactor(PathTrie): Better more consistent error messages --- lib/src/router/path_trie.dart | 61 +++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/lib/src/router/path_trie.dart b/lib/src/router/path_trie.dart index 283509d8..58f72f08 100644 --- a/lib/src/router/path_trie.dart +++ b/lib/src/router/path_trie.dart @@ -74,13 +74,12 @@ final class PathTrie { final currentNode = _build(normalizedPath); // Mark the end node and handle potential overwrites if (currentNode.value != null) { - throw ArgumentError( - 'Value already registered: ' - 'Existing: "${currentNode.value}" ' - 'New: "$value" ' - 'for path $normalizedPath', - 'normalizedPath', - ); + throw ArgumentError.value( + normalizedPath, + 'normalizedPath', + 'Value already registered: ' + 'Existing: "${currentNode.value}" ' + 'New: "$value"'); } currentNode.value = value; } @@ -214,9 +213,18 @@ final class PathTrie { // Helper function @pragma('vm:prefer-inline') void isA>( - final _DynamicSegment? dynamicSegment) { + final _DynamicSegment? dynamicSegment, final int segmentNo) { if (dynamicSegment != null && dynamicSegment is! U) { - throw ArgumentError(); + final kind = switch (dynamicSegment) { + _Parameter() => 'parameter ":name"', + _Wildcard() => 'wildcard "*"', + _Tail() => 'tail "**"', + }; + normalizedPath.raiseInvalidSegment( + segmentNo, + 'Conflicting segment type at the same level: ' + 'Existing: $kind, ' + 'New: "$U"'); } } @@ -227,30 +235,28 @@ final class PathTrie { if (segment.startsWith('**')) { // Handle tail segment if (segment != '**') { - throw ArgumentError.value(normalizedPath, 'normalizedPath', - '"$segment" not allowed. Starts with "**"'); + normalizedPath.raiseInvalidSegment(i, 'Starts with "**"'); } if (i < segments.length - 1) { - throw ArgumentError.value(normalizedPath, 'normalizedPath', + normalizedPath.raiseInvalidSegment(i, 'Tail segment (**) must be the last segment in the path definition.'); } - isA<_Tail>(dynamicSegment); + isA<_Tail>(dynamicSegment, i); currentNode = (currentNode.dynamicSegment ??= _Tail()).node; } else if (segment.startsWith('*')) { // Handle wildcard segment if (segment != '*') { - throw ArgumentError.value(normalizedPath, 'normalizedPath', - '"$segment" not allowed. Starts with "*"'); + normalizedPath.raiseInvalidSegment(i, 'Starts with "*"'); } - isA<_Wildcard>(dynamicSegment); + isA<_Wildcard>(dynamicSegment, i); currentNode = (currentNode.dynamicSegment ??= _Wildcard()).node; } else if (segment.startsWith(':')) { // Handle parameter segment - isA<_Parameter>(dynamicSegment); + isA<_Parameter>(dynamicSegment, i); final paramName = segment.substring(1).trim(); if (paramName.isEmpty) { - throw ArgumentError.value(normalizedPath, 'normalizedPath', - 'Parameter name cannot be empty'); + normalizedPath.raiseInvalidSegment( + i, 'Parameter name cannot be empty'); } // Ensure parameter child exists and handle name conflicts var parameter = dynamicSegment as _Parameter?; @@ -258,12 +264,11 @@ final class PathTrie { parameter = _Parameter(paramName); } else if (parameter.name != paramName) { // Throw an error if a different parameter name already exists at this level. - throw ArgumentError( + throw normalizedPath.raiseInvalidSegment( + i, 'Conflicting parameter names at the same level: ' - 'Existing: ":${parameter.name}", ' - 'New: ":$paramName" ' - 'for path $normalizedPath', - 'normalizedPath', + 'Existing: ":${parameter.name}", ' + 'New: ":$paramName"', ); } currentNode.dynamicSegment = parameter; @@ -367,3 +372,11 @@ final class PathTrie { : null; } } + +extension on NormalizedPath { + Never raiseInvalidSegment(final int segmentNo, final String message, + {final String name = 'normalizedPath'}) { + throw ArgumentError.value(this, name, + 'Segment no $segmentNo: "${segments[segmentNo]}" is invalid. $message'); + } +} From 87420acdf243abe8b7a63ee51abfd106787bf254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 16 May 2025 14:57:16 +0200 Subject: [PATCH 07/10] fix: Nits --- lib/src/router/path_trie.dart | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/src/router/path_trie.dart b/lib/src/router/path_trie.dart index 58f72f08..21d84c4a 100644 --- a/lib/src/router/path_trie.dart +++ b/lib/src/router/path_trie.dart @@ -15,7 +15,7 @@ final class LookupResult { /// If a match, does not consume the full path, then stores the [remaining] /// - /// This can only happen with path that ends with a tail-match /:: or /::name, + /// This can only happen with a path that ends with a tail segment `/**`, /// otherwise it will be empty. final NormalizedPath remaining; @@ -55,8 +55,8 @@ final class _Tail extends _DynamicSegment {} /// A Trie (prefix tree) data structure optimized for matching URL paths. /// -/// Supports literal segments and parameterized segments (e.g., `:id`). Allows -/// associating a value of type [T] with each complete path. +/// Supports literal segments, parameterized segments (e.g., `:id`), wildcard segments (`*`), +/// and tail segments (`**`). Allows associating a value of type [T] with each complete path. final class PathTrie { // Note: not final since we update in attach var _root = _TrieNode(); @@ -215,16 +215,11 @@ final class PathTrie { void isA>( final _DynamicSegment? dynamicSegment, final int segmentNo) { if (dynamicSegment != null && dynamicSegment is! U) { - final kind = switch (dynamicSegment) { - _Parameter() => 'parameter ":name"', - _Wildcard() => 'wildcard "*"', - _Tail() => 'tail "**"', - }; normalizedPath.raiseInvalidSegment( segmentNo, 'Conflicting segment type at the same level: ' - 'Existing: $kind, ' - 'New: "$U"'); + 'Existing: ${dynamicSegment.runtimeType}, ' + 'New: $U'); } } @@ -264,7 +259,7 @@ final class PathTrie { parameter = _Parameter(paramName); } else if (parameter.name != paramName) { // Throw an error if a different parameter name already exists at this level. - throw normalizedPath.raiseInvalidSegment( + normalizedPath.raiseInvalidSegment( i, 'Conflicting parameter names at the same level: ' 'Existing: ":${parameter.name}", ' From e253ee5f0186c003f845e0471baaf38ecfe5170d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 16 May 2025 17:50:44 +0200 Subject: [PATCH 08/10] Update test/router/path_trie_crud_test.dart Co-authored-by: Timm Preetz <52437+tp@users.noreply.github.com> --- test/router/path_trie_crud_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/router/path_trie_crud_test.dart b/test/router/path_trie_crud_test.dart index 3db7c670..f5c9c1aa 100644 --- a/test/router/path_trie_crud_test.dart +++ b/test/router/path_trie_crud_test.dart @@ -406,8 +406,7 @@ void main() { final specificLookup = trie.lookup(NormalizedPath('/files/specific/file.txt')); expect(specificLookup?.value, 40, - reason: - 'More specific path should ideally remain if structured correctly.'); + reason: 'More specific path should remain'); }); test( From 95f7ffc96efaca68afd3ca89bdb6592746b02a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 16 May 2025 17:51:17 +0200 Subject: [PATCH 09/10] docs: Design: Typed context pipeline for Relic --- DESIGN_typed_context_pipeline.md | 319 +++++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 DESIGN_typed_context_pipeline.md diff --git a/DESIGN_typed_context_pipeline.md b/DESIGN_typed_context_pipeline.md new file mode 100644 index 00000000..e5e5f0ea --- /dev/null +++ b/DESIGN_typed_context_pipeline.md @@ -0,0 +1,319 @@ +# Design: Typed Context Pipeline for Relic + +## 1. Introduction and Goals + +This document proposes a design for a typed context pipeline in Relic. The primary goal is to achieve **compile-time safety** for middleware and handler composition. This means the Dart analyzer should be able to verify that if a handler (or subsequent middleware) expects certain data to be present in its request context (e.g., an authenticated `User` object), the necessary preceding middleware (e.g., an `AuthenticationMiddleware`) has been correctly configured in the pipeline. + +This approach aims to: +* Catch pipeline configuration errors at compile-time rather than runtime. +* Improve developer ergonomics by providing clear, type-safe access to context data. +* Maintain high performance by leveraging zero-cost abstractions like extension types. +* Avoid a "class explosion" that might occur if every combination of context data required a distinct context class. + +## 2. Core Components + +The system relies on a few key components: + +### 2.1. Relic's `RequestContext` and Stable Request Token + +This design utilizes Relic's existing `RequestContext` (defined in `relic/lib/src/adapter/context.dart`). The key aspects of `RequestContext` relevant to this design are: +* It provides access to the current `Request` object (e.g., via a `request` getter). +* It contains a stable `token` (e.g., via a `token` getter). This `token` is a unique identifier for the entire lifecycle of a single request and remains constant even if the `RequestContext` instance itself undergoes state transitions (e.g., from `NewContext` to `ResponseContext`). + +All request-scoped data attached via `Expando` (managed by `ContextProperty`) will be keyed off this stable `requestToken`. The extension type views defined in this design will wrap an instance of Relic's `RequestContext`. + +```dart +// Note: This design uses Relic's existing RequestContext. +// For illustration, its relevant properties would be: +class RequestContext { + final Request request; + final Object token; // The stable, unique-per-request token + // ... +} +// Data is attached via Expandos keyed by 'token', managed by +// ContextProperty and accessed through context views. +``` + +### 2.2. Data Classes + +These are simple Dart classes representing the data that middleware can add to the context. + +```dart +class User { + final String id; + final String email; + User({required this.id, required this.email}); +} + +class Session { + final String sessionId; + DateTime expiresAt; + Session({required this.sessionId, required this.expiresAt}); +} +``` + +### 2.3. `ContextProperty` Helper + +To simplify and standardize the management of `Expando`-based context data, a helper class `ContextProperty` is introduced. This class encapsulates an `Expando` and ensures that data is consistently keyed off the stable `requestToken`. + +```dart +class ContextProperty { + final Expando _expando; // Use token from RequestContext as anchor + final String? _debugName; // Optional: for Expando's name + + ContextProperty([this._debugName]) : _expando = Expando(_debugName); + + T get(RequestContext requestContext) { + final value = _expando[requestContext.token]; + if (value == null) { + throw StateError( + 'ContextProperty value not found. Property: ${_debugName ?? T.toString()}. ' + 'Ensure middleware has set this value for the request token.'); + } + return value; + } + + T? getOrNull(RequestContext requestContext) { + return _expando[requestContext.token]; + } + + void set(RequestContext requestContext, T value) { + _expando[requestContext.token] = value; + } + + bool exists(RequestContext requestContext) { + return _expando[requestContext.token] != null; + } + + void clear(RequestContext requestContext) { + _expando[requestContext.token] = null; // Clears the association in Expando + } +} +``` +Modules responsible for specific pieces of context data (e.g., `User`, `Session`) will define a private static `ContextProperty` instance. + +### 2.4. Extension Types for Context Views + +Extension types are zero-cost abstractions (wrappers) over an instance of Relic's `RequestContext`. They provide a type-safe "view" or "contract" for accessing and attaching specific data, using `ContextProperty` instances internally. + +Each view typically provides: +* Getters for the data it represents (e.g., `UserContextView.user`). +* Methods to attach or set its data (e.g., `UserContextView.attachUser(User user)`). These methods use the appropriate `ContextProperty` and the `requestToken`. + +```dart +// Define a ContextProperty for User data. +// This would typically be a static final field, private to its library, +// in a relevant class or top-level. +final _userProperty = ContextProperty('relic.auth.user'); + +// Base view that all request contexts can be seen as initially. +// It wraps Relic's RequestContext. +extension type BaseContextView(RequestContext _relicContext) { + Request get request => _relicContext.request; +} + +// A view indicating that User information is available. +extension type UserContextView(RequestContext _relicContext) implements BaseContextView { + User get user => _userProperty.get(_relicContext); + + void attachUser(User user) { + _userProperty.set(_relicContext, user); + } + + // Optional: to check for user presence or get a nullable user + User? get userOrNull => _userProperty.getOrNull(_relicContext); + bool get hasUser => _userProperty.exists(_relicContext); +} + +// A view indicating that Session information is available. +extension type SessionContextView(RequestContext _relicContext) implements BaseContextView { + Session get session => _sessionProperty.get(_relicContext); + + void attachSession(Session session) { + _sessionProperty.set(_relicContext, session); + } +} +final _sessionProperty = ContextProperty('relic.session'); + +// A composite view indicating both User and Session information are available. +extension type UserSessionContextView(RequestContext _relicContext) implements UserContextView, SessionContextView { + // Getters for 'user' and 'session' are inherited via UserContextView and SessionContextView. + // The 'attachUser' and 'attachSession' methods are also available if needed, + // though typically data is attached by the specific middleware responsible for it. +} +``` +Middleware will use these view-specific `attach` methods (e.g., `userView.attachUser(newUser)`), which internally leverage `ContextProperty` to manage data associated with the `requestToken`. + +## 3. Middleware Definition + +Middleware are defined as functions (or methods on stateless service objects) that: +1. Take an input context view (e.g., `BaseContextView`, `UserContextView`). +2. Perform their logic, attaching data to the stable `requestToken` (obtained via `inputView.requestToken` or `inputView._relicContext.token`). This is done using the view's `attach` methods (e.g., `inputView.attachUser(user)`), which internally use `ContextProperty`. +3. Return an output context view (e.g., `UserContextView`, `UserSessionContextView`) that wraps the *same* `RequestContext` instance. The type of the returned view signals the new capabilities/data available via the `requestToken`. + +```dart +// This middleware takes a BaseContextView, authenticates the request, +// attaches a User object (via ContextProperty and the requestToken), and returns a UserContextView. +UserContextView authenticationMiddleware(BaseContextView inputView) { + // Simplified authentication logic + final token = inputView.request.headers['Authorization']; + // Create the UserContextView to use its `attachUser` method. + // The underlying RequestContext (and thus its token) is passed along. + final userView = UserContextView(inputView._relicContext); + + if (token == 'Bearer valid-token') { + final user = User(id: 'user-123', email: 'user@example.com'); + userView.attachUser(user); // Use the view's method to attach the user + } else { + // Handle failed authentication: + // Option A: Throw an error that the server translates to a 401/403 response. + // throw AuthenticationError('Invalid or missing token'); + // Option B: Do not set the user. The UserContextView.user getter would then fail, + // or UserContextView would need to expose 'User? get user' or 'bool get hasUser'. + // For this design, we assume successful authentication is required if returning UserContextView. + // If authentication is optional, the middleware might return a different view type or + // UserContextView.user might be nullable. + // Forcing a User to be present if returning UserContextView makes the contract stronger. + } + + // Return a UserContextView, wrapping the same (now modified) CoreRequestContext + return userView; +} +``` + +## 4. Type-Safe `PipelineBuilder` + +The `PipelineBuilder` is a generic class responsible for composing middleware and a final handler in a type-safe manner. It uses Dart's generic type system to track the "shape" (capabilities) of the context view as it evolves through the pipeline. + +```dart +class PipelineBuilder { + /// The function representing the composed chain of middleware so far. + /// It transforms an input view (TCurrentChainInputView) to an output view (TCurrentChainOutputView). + final TCurrentChainOutputView Function(TCurrentChainInputView) _chain; + + PipelineBuilder._(this._chain); + + /// Starts a new pipeline. + /// The initial view for the chain is `BaseContextView`. + static PipelineBuilder start() { + // The initial chain is an identity function: it receives a BaseContextView and returns it. + return PipelineBuilder._((BaseContextView view) => view); + } + + /// Adds a middleware to the current pipeline. + /// - `middleware`: A function that takes the output view of the current chain (`TCurrentChainOutputView`) + /// and produces a new view (`TNextChainOutputView`). + /// Returns a new PipelineBuilder instance representing the extended chain. + PipelineBuilder add( + TNextChainOutputView Function(TCurrentChainOutputView currentView) middleware, + ) { + // Compose the existing chain with the new middleware: + // The new chain takes the original input (TCurrentChainInputView), + // applies the old chain to get TCurrentChainOutputView, + // then applies the new middleware to get TNextChainOutputView. + return PipelineBuilder._( + (TCurrentChainInputView initialView) { + final previousOutput = _chain(initialView); + return middleware(previousOutput); + }); + } + + /// Finalizes the pipeline with a handler. + /// - `handler`: A function that takes the final output view of the middleware chain (`TCurrentChainOutputView`) + /// and produces a `FutureOr`. + /// Returns a single function that takes a `Request`, sets up the context, executes the pipeline, + /// and returns the `FutureOr`. + FutureOr Function(NewContext request) build( + FutureOr Function(TCurrentChainOutputView finalView) handler, + ) { + // The fully composed chain from the initial TCurrentChainInputView (which should be BaseContextView for a `start()`ed pipeline) + // to the final TCurrentChainOutputView. + final TCurrentChainOutputView Function(TCurrentChainInputView) + completeMiddlewareChain = _chain; + + return (NewContext ctx) { + // This cast assumes the pipeline was started with `PipelineBuilder.start()`, + // making TCurrentChainInputView effectively BaseContextView. + final initialView = + BaseContextView(ctx) as TCurrentChainInputView; + + // Execute the middleware chain. + final finalView = completeMiddlewareChain(initialView); + + // Execute the final handler with the processed context view. + return handler(finalView); + }; + } +} +``` + +## 5. Usage Example + +This demonstrates how to build a pipeline and how type errors would be caught. + +```dart +// Assuming SessionMiddleware is: +// UserSessionContextView sessionMiddleware(UserContextView userView) { ... } + +// Define a handler that expects both User and Session data. +Future mySecureHandler(UserSessionContextView context) async { + final user = context.user; + final session = context.session; + return Response.ok('User: ${user.email}, Session ID: ${session.sessionId}'); +} + +void setupServer() { + final requestHandler = PipelineBuilder.start() // Starts with BaseContextView + .add(authenticationMiddleware) // Output view: UserContextView + .add(sessionMiddleware) // Input: UserContextView, Output: UserSessionContextView + .build(mySecureHandler); // Handler expects UserSessionContextView - OK! + + // This handler can now be passed to Relic's server serving mechanism. + // relicServe(requestHandler, ...); + + // Example of a compile-time error: + // final faultyHandler = PipelineBuilder.start() + // .add(authenticationMiddleware) // Output: UserContextView + // // Missing sessionMiddleware + // .build(mySecureHandler); // COMPILE ERROR: mySecureHandler expects UserSessionContextView, + // // but pipeline only guarantees UserContextView. +} +``` + +## 6. Benefits + +* **Compile-Time Safety**: The primary goal. Misconfigured pipelines (e.g., missing middleware, incorrect order affecting context data) are caught by the Dart analyzer. +* **Improved Developer Ergonomics**: + * Handlers and middleware can declare precisely the context view (and thus data) they expect. + * Access to context data via extension type getters is type-safe and clear (e.g., `context.user`). +* **Minimal Runtime Overhead for Views**: Extension types are intended to be zero-cost compile-time wrappers. The `ContextProperty` helper encapsulates `Expando` lookups/attachments, which are generally efficient. +* **No Class Explosion**: Avoids needing a distinct context `class` for every possible combination of middleware. Extension types provide views, and `ContextProperty` manages data association with the stable `requestToken`. +* **Clarity and Documentation**: The type signatures of middleware and handlers explicitly document their context dependencies. View methods (e.g., `attachUser`) and `ContextProperty` provide clear, discoverable APIs for data management. +* **Modularity & Encapsulation**: `ContextProperty` encapsulates `Expando` usage. Modules define their data properties cleanly. + +## 7. Middleware Paradigm Shift and Implications + +This typed pipeline introduces a shift from the traditional `Middleware = Handler Function(Handler innerHandler)` pattern previously used. Understanding these changes is crucial: + +* **New Middleware Signature**: Middleware in this design are functions with a signature like `OutputView Function(InputView)`. They transform context views rather than wrapping an inner handler. +* **Linear Chain**: The `PipelineBuilder` composes middleware into a linear chain of context transformations. Each middleware is expected to process the context and pass control (via its return value) to the next stage defined by the builder. +* **Short-Circuiting (e.g., Denying Access)**: + * Middleware should not directly return a `Response` to short-circuit the pipeline. + * Instead, if a middleware needs to stop processing and return an error (e.g., an authentication middleware denying access due to an invalid token), it should **throw a specific exception** (e.g., `AuthorizationRequiredError("Invalid token")`, `PaymentRequiredError()`). + * The main server error handling logic (external to this pipeline execution) would then catch these specific exceptions and convert them into appropriate HTTP `Response` objects (e.g., status codes 401, 403, 402). + * This keeps middleware focused on context validation/transformation, with exceptions managing early exits. +* **Complex Conditional Logic or "Nested" Operations**: + * The `InputView -> OutputView` signature doesn't inherently support conditional invocation of different sub-handlers or complex branching within the middleware itself in the same way the `Handler Function(Handler)` pattern does. + * Such logic is often best placed within the **final handler** (the function passed to `PipelineBuilder.build(...)`). This handler receives the fully prepared, type-safe context. Inside this handler, developers can use standard Dart control flow (`if/else`, `switch`) or call other services/functions which might internally manage their own complex operations (potentially even using other `PipelineBuilder` instances for sub-tasks if appropriate, though this is advanced). + * Alternatively, a middleware could add data to the context that signals a specific route or action, which subsequent middleware or the final handler then interprets. +* **Trade-offs**: + * **Gained**: Strong compile-time type safety for the data flowing through the request context. This significantly reduces a class of runtime errors due to misconfigured pipelines. + * **Different Flexibility**: Some dynamic flexibility found in the `Handler Function(Handler)` pattern (e.g., complex around-logic, dynamically choosing the next handler in the chain from within a middleware) is handled differently (e.g., via exceptions, or by moving logic into handlers). For many common middleware tasks (logging, data enrichment, simple auth checks), the typed pipeline offers a clearer and safer model. + +## 8. General Considerations + +* **PipelineBuilder Complexity**: The implementation of `PipelineBuilder`, especially its generic typing, is somewhat complex, but this complexity is encapsulated for the end-user. +* **Boilerplate for `ContextProperty` and Views**: Each new piece of context data requires defining a `ContextProperty` instance and corresponding view methods. However, this is more structured and less error-prone than raw `Expando` usage. +* **Learning Curve**: Developers using the framework will need to understand context views, `ContextProperty`, the role of `requestToken`, the pipeline builder, and the implications of the new middleware paradigm. +* **Discipline with `requestToken`**: The `ContextProperty` helper ensures that data is keyed off the stable `token` within the `RequestContext`, mitigating direct misuse of `Expando`s with transient `RequestContext` instances themselves as keys. +* **Middleware Return Types**: Middleware authors must be careful to return the correct context view type that accurately reflects the data they've attached via `ContextProperty` and the `requestToken`. From 935e0dd4f21635a28f59535f2cd4c039c1e3c9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 16 May 2025 20:15:48 +0200 Subject: [PATCH 10/10] docs: Add section on routing to typed context pipeline design - Move DESIGN_typed_context_pipeline into design folder - Add runnable simplified example code in design/appendix_a.dart --- .../DESIGN_typed_context_pipeline.md | 134 +++++++++- design/appendix_a.dart | 246 ++++++++++++++++++ 2 files changed, 379 insertions(+), 1 deletion(-) rename DESIGN_typed_context_pipeline.md => design/DESIGN_typed_context_pipeline.md (68%) create mode 100644 design/appendix_a.dart diff --git a/DESIGN_typed_context_pipeline.md b/design/DESIGN_typed_context_pipeline.md similarity index 68% rename from DESIGN_typed_context_pipeline.md rename to design/DESIGN_typed_context_pipeline.md index e5e5f0ea..60df874f 100644 --- a/DESIGN_typed_context_pipeline.md +++ b/design/DESIGN_typed_context_pipeline.md @@ -310,10 +310,142 @@ This typed pipeline introduces a shift from the traditional `Middleware = Handle * **Gained**: Strong compile-time type safety for the data flowing through the request context. This significantly reduces a class of runtime errors due to misconfigured pipelines. * **Different Flexibility**: Some dynamic flexibility found in the `Handler Function(Handler)` pattern (e.g., complex around-logic, dynamically choosing the next handler in the chain from within a middleware) is handled differently (e.g., via exceptions, or by moving logic into handlers). For many common middleware tasks (logging, data enrichment, simple auth checks), the typed pipeline offers a clearer and safer model. -## 8. General Considerations +## 8. Integrating with Routing + +The typed context pipeline is designed to prepare a rich, type-safe context that can then be utilized by a routing system to dispatch requests to appropriate endpoint handlers. Relic's `Router` class can be seamlessly integrated with this pipeline. + +The core idea is that the `PipelineBuilder` sets up a chain of common middleware (e.g., authentication, session management, logging). The final function passed to `PipelineBuilder.build(...)` will be a "routing dispatcher." This dispatcher uses the context prepared by the common middleware, performs route matching, potentially adds route-specific data (like path parameters) to the context, and then executes the endpoint handler chosen by the router. + +### 8.1. Context for Route Parameters + +Endpoint handlers often need access to path parameters extracted during routing (e.g., the `:id` in `/users/:id`). A `ContextProperty` and corresponding view should be defined for these. + +```dart +// Example: Data class for route parameters +class RouteParameters { + final Map params; + RouteParameters(this.params); + + String? operator [](Symbol key) => params[key]; + // Potentially add other useful accessors +} + +// Example: Private ContextProperty for route parameters +// (Typically defined in a routing-related module/library) +final _routeParametersProperty = + ContextProperty('relic.routing.parameters'); +``` + +### 8.2. Context Views for Endpoint Handlers + +Endpoint handlers will require a context view that provides access to both the common context data (prepared by the initial pipeline) and the specific `RouteParameters`. + +```dart +// Example: A view that combines UserContext (from common pipeline) and RouteParameters +// (Assumes UserContextView is already defined) +extension type UserRouteContextView(RequestContext _relicContext) implements UserContextView { + RouteParameters get routeParams => _routeParametersProperty.get(_relicContext); + + // This method will be called by the routing dispatcher after parameters are extracted. + void attachRouteParameters(RouteParameters params) { + _routeParametersProperty.set(_relicContext, params); + } +} + +// Other combinations can be created as needed (e.g., BaseRouteContextView, UserSessionRouteContextView). +``` + +### 8.3. Router's Generic Type `T` + +The generic type `T` in `Router` will represent the actual endpoint handler functions. These functions will expect an enriched context view that includes common context data and route parameters. + +For example, `T` could be: +`FutureOr Function(UserRouteContextView context)` + +### 8.4. The Routing Dispatcher Function + +This function is passed to `PipelineBuilder.build(...)`. It receives the context prepared by the common middleware chain (e.g., `UserContextView`). Its responsibilities are: +1. Use the incoming request details (from the context) to perform a route lookup via `Router`. +2. Handle cases where no route is matched (e.g., by throwing a `RouteNotFoundException` or returning a 404 `Response`). +3. If a route is matched, extract the endpoint handler and any path parameters. +4. Create the specific context view required by the endpoint handler (e.g., `UserRouteContextView`), attaching the extracted `RouteParameters` to it. +5. Execute the chosen endpoint handler with this enriched context. + +```dart +// Example: Routing Dispatcher +// Assume 'myAppRouter' is an instance of Router Function(UserRouteContextView)> +// Assume 'UserContextView' is the output view from the common middleware pipeline. + +FutureOr routingDispatcher(UserContextView commonContext) { + final request = commonContext.request; + + // Perform route lookup using Relic's Router. + final lookupResult = myAppRouter.lookup( + request.method.convert(), // Or however method is represented + request.uri.path); + + if (lookupResult == null || lookupResult.value == null) { + // Option 1: Throw a specific RouteNotFoundException for centralized error handling. + throw RouteNotFoundException( + 'Route not found for ${request.method.value} ${request.uri.path}'); + // Option 2: Directly return a 404 Response (less flexible for global error handling). + // return Response.notFound(...); + } + + final endpointHandler = lookupResult.value; + final pathParams = RouteParameters(lookupResult.parameters); + + // Create the specific context view for the endpoint handler by wrapping the same + // underlying RequestContext and attaching the extracted route parameters. + final endpointContext = UserRouteContextView(commonContext._relicContext); + endpointContext.attachRouteParameters(pathParams); + + // Execute the chosen endpoint handler. + return endpointHandler(endpointContext); +} +``` + +### 8.5. Pipeline Setup with Routing + +The `PipelineBuilder` is used to construct the common middleware chain, with the `routingDispatcher` as the final step. + +```dart +// Example: In your server setup +void setupServer(Router Function(UserRouteContextView)> myAppRouter) { // Pass your router + final requestHandler = PipelineBuilder.start() // Input: BaseContextView + .add(authenticationMiddleware) // Output: UserContextView + // ... other common middleware (e.g., session, logging) ... + // The output view of the last common middleware must match + // the input view expected by `routingDispatcher`. + .build(routingDispatcher); // `routingDispatcher` uses UserContextView + + // This `requestHandler` can now be used with Relic's server mechanism. + // e.g., relicServe(requestHandler, ...); +} +``` + +### 8.6. Implications + +* **Separation of Concerns**: Common middleware (auth, logging, sessions) are managed by the `PipelineBuilder`, preparing a general-purpose typed context. The `routingDispatcher` then handles routing-specific concerns and further context enrichment (route parameters) for the final endpoint handlers. +* **Type Safety End-to-End**: Endpoint handlers receive a context view that is guaranteed by the type system to contain all necessary data from both the common pipeline and the routing process. +* **Flexibility**: This pattern allows different sets of common middleware to be composed for distinct parts of an application. By creating multiple `PipelineBuilder` instances, each tailored with specific middleware and culminating in a different routing dispatcher (or final handler), an application can support varied requirements across its modules. + + For example, an application might have: + * An `/api/v1` section with `apiAuthMiddleware` leading to an API router, producing an `ApiUserContextView`. The `PipelineBuilder.build(...)` for this would result in an `apiV1RequestHandler: FutureOr Function(NewContext)`. + * An `/admin` section with `adminAuthMiddleware` and `sessionMiddleware` leading to an admin router, producing an `AdminSessionContextView`. This would result in an `adminRequestHandler: FutureOr Function(NewContext)`. + * A `/public` section with `cachingMiddleware` leading to a simpler router, using a `BaseContextView`. This would result in a `publicRequestHandler: FutureOr Function(NewContext)`. + + A top-level Relic `Router Function(NewContext)>` can then be used to select the appropriate pre-built pipeline handler based on path prefixes (e.g., requests to `/api/v1/**` lead to invoking `apiV1RequestHandler`). The main server entry point would create the initial `NewContext`, look up the target pipeline handler using this top-level router, and then pass the `NewContext` to the chosen handler. This top-level router doesn't deal with the typed context views itself but delegates to handlers that encapsulate their own typed pipelines. This maintains type safety within each specialized pipeline while allowing for a clean, router-based architecture at the highest level. *See Appendix A for a conceptual code sketch illustrating this top-level routing approach.* + +## 9. General Considerations * **PipelineBuilder Complexity**: The implementation of `PipelineBuilder`, especially its generic typing, is somewhat complex, but this complexity is encapsulated for the end-user. * **Boilerplate for `ContextProperty` and Views**: Each new piece of context data requires defining a `ContextProperty` instance and corresponding view methods. However, this is more structured and less error-prone than raw `Expando` usage. * **Learning Curve**: Developers using the framework will need to understand context views, `ContextProperty`, the role of `requestToken`, the pipeline builder, and the implications of the new middleware paradigm. * **Discipline with `requestToken`**: The `ContextProperty` helper ensures that data is keyed off the stable `token` within the `RequestContext`, mitigating direct misuse of `Expando`s with transient `RequestContext` instances themselves as keys. * **Middleware Return Types**: Middleware authors must be careful to return the correct context view type that accurately reflects the data they've attached via `ContextProperty` and the `requestToken`. + + +## Appendix A: Conceptual Code Example for Top-Level Routing + +This appendix provides a conceptual, runnable (with stubs) Dart code sketch to illustrate how different `PipelineBuilder` instances can create specialized request handling chains, and how a top-level router can direct traffic to the appropriate chain, all starting with a common `NewContext`. See [appendix_a.dart](appendix_a.dart). diff --git a/design/appendix_a.dart b/design/appendix_a.dart new file mode 100644 index 00000000..0c0fe4cf --- /dev/null +++ b/design/appendix_a.dart @@ -0,0 +1,246 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'package:relic/src/router/router.dart'; + +// === Core Stubs (simplified) === +class Request { + final Uri uri; + final Method method; + final Map headers; + Request({required this.uri, required this.method, this.headers = const {}}); +} + +class Response { + final int statusCode; + final String body; + Response(this.statusCode, this.body); + + static Response ok(final String body) => Response(200, body); + static Response notFound(final String body) => Response(404, body); + static Response unauthorized(final String body) => Response(401, body); +} + +class RequestContext { + final Request request; + final Object token; // Stable unique token + RequestContext(this.request, this.token); +} + +class NewContext extends RequestContext { + NewContext(super.request, super.token); +} + +// === ContextProperty and Views Stubs === +class ContextProperty { + final Expando _expando; + final String? _debugName; + + ContextProperty([this._debugName]) : _expando = Expando(_debugName); + T get(final RequestContext ctx) { + final val = _expando[ctx.token]; + if (val == null) { + throw StateError('Property ${_debugName ?? T.toString()} not found'); + } + return val; + } + + void set(final RequestContext ctx, final T val) => _expando[ctx.token] = val; +} + +extension type BaseContextView(RequestContext _relicContext) { + Request get request => _relicContext.request; +} + +// User data and view +class User { + final String id; + final String name; + User(this.id, this.name); +} + +final _userProperty = ContextProperty('user'); +extension type UserContextView(RequestContext _relicContext) + implements BaseContextView { + User get user => _userProperty.get(_relicContext); + void attachUser(final User user) => _userProperty.set(_relicContext, user); +} + +// Admin data and view +class AdminRole { + final String roleName; + AdminRole(this.roleName); +} + +final _adminRoleProperty = ContextProperty('admin_role'); +extension type AdminContextView(RequestContext _relicContext) + implements UserContextView { + // Admin also has User + AdminRole get adminRole => _adminRoleProperty.get(_relicContext); + void attachAdminRole(final AdminRole role) => + _adminRoleProperty.set(_relicContext, role); +} + +// === PipelineBuilder Stub === +class PipelineBuilder { + final TOutView Function(TInView) _chain; + PipelineBuilder._(this._chain); + + static PipelineBuilder start() { + return PipelineBuilder._((final BaseContextView view) => view); + } + + PipelineBuilder add( + final TNextOutView Function(TOutView currentView) middleware, + ) { + return PipelineBuilder._( + (final TInView initialView) { + final previousOutput = _chain(initialView); + return middleware(previousOutput); + }); + } + + FutureOr Function(NewContext initialContext) build( + final FutureOr Function(TOutView finalView) handler, + ) { + final TOutView Function(BaseContextView) builtChain = + _chain as TOutView Function(BaseContextView); + return (final NewContext initialContext) { + final initialView = BaseContextView(initialContext) + as TInView; // Cast for the chain start + final finalView = builtChain(initialView); + return handler(finalView); + }; + } +} + +// === Placeholder Middleware === +// API Auth: Adds User, returns UserContextView +UserContextView apiAuthMiddleware(final BaseContextView inputView) { + print('API Auth Middleware Running for ${inputView.request.uri.path}'); + if (inputView.request.headers['X-API-Key'] == 'secret-api-key') { + final userView = UserContextView(inputView._relicContext); + userView.attachUser(User('api_user_123', 'API User')); + return userView; + } + throw Response(401, 'API Key Required'); // Short-circuiting via exception +} + +// Admin Auth: Adds User and AdminRole, returns AdminContextView +AdminContextView adminAuthMiddleware(final BaseContextView inputView) { + print('Admin Auth Middleware Running for ${inputView.request.uri.path}'); + if (inputView.request.headers['X-Admin-Token'] == + 'super-secret-admin-token') { + final userView = UserContextView(inputView._relicContext); + userView.attachUser(User('admin_user_007', 'Admin User')); + + final adminView = AdminContextView(inputView._relicContext); + adminView.attachAdminRole(AdminRole('super_admin')); + return adminView; + } + throw Response(401, 'Admin Token Required'); +} + +T generalLoggingMiddleware(final T inputView) { + // Weird analyzer bug inputView cannot be null here. + // Compiler and interpreter don't complain. Trying: + // final req = inputView!.request; + // won't work ¯\_(ツ)_/¯ + // ignore: unchecked_use_of_nullable_value + final req = inputView.request; + print('Logging: ${req.method} ${req.uri.path}'); + return inputView; +} + +// === Endpoint Handlers === +FutureOr handleApiUserDetails(final UserContextView context) { + print('Handling API User Details for ${context.user.name}'); + return Response.ok('API User: ${context.user.name} (id: ${context.user.id})'); +} + +FutureOr handleAdminDashboard(final AdminContextView context) { + print( + 'Handling Admin Dashboard for ${context.user.name} (${context.adminRole.roleName})'); + return Response.ok( + 'Admin: ${context.user.name}, Role: ${context.adminRole.roleName}'); +} + +FutureOr handlePublicInfo(final BaseContextView context) { + print('Handling Public Info for ${context.request.uri.path}'); + return Response.ok('This is public information.'); +} + +typedef Handler = FutureOr Function(NewContext); + +void main() async { + // === 1. Build Specialized Pipeline Handlers === + final apiHandler = PipelineBuilder.start() + .add(generalLoggingMiddleware) + .add(apiAuthMiddleware) + .build(handleApiUserDetails); + + final adminHandler = PipelineBuilder.start() + .add(generalLoggingMiddleware) + .add(adminAuthMiddleware) + .build(handleAdminDashboard); + + final publicHandler = PipelineBuilder.start() + .add(generalLoggingMiddleware) + .build(handlePublicInfo); + + // === 2. Configure Top-Level Router === + final topLevelRouter = Router() + ..any('/api/users/**', apiHandler) + ..any('/admin/dashboard/**', adminHandler) + ..any('/public/**', publicHandler); + + // === 3. Main Server Request Handler === + FutureOr mainServerRequestHandler(final Request request) { + final initialContext = NewContext(request, Object()); + print('\nProcessing ${request.method} ${request.uri.path}'); + + try { + final targetPipelineHandler = + topLevelRouter.lookup(request.method, request.uri.path)?.value; + + if (targetPipelineHandler != null) { + return targetPipelineHandler(initialContext); + } else { + print('No top-level route matched.'); + return Response.notFound('Service endpoint not found.'); + } + } on Response catch (e) { + print('Request short-circuited with response: ${e.statusCode}'); + return e; + } catch (e) { + print('Unhandled error: $e'); + return Response(500, 'Internal Server Error'); + } + } + + // === Simulate some requests === + final requests = [ + Request( + uri: Uri.parse('/api/users/123'), + method: Method.get, + headers: {'X-API-Key': 'secret-api-key'}, + ), + Request( + uri: Uri.parse('/api/users/456'), + method: Method.get, + headers: {'X-API-Key': 'wrong-key'}, + ), + Request( + uri: Uri.parse('/admin/dashboard'), + method: Method.get, + headers: {'X-Admin-Token': 'super-secret-admin-token'}, + ), + Request(uri: Uri.parse('/public/info'), method: Method.get), + Request(uri: Uri.parse('/unknown/path'), method: Method.get), + ]; + + for (final req in requests) { + final res = await mainServerRequestHandler(req); + print('Response for ${req.uri.path}: ${res.statusCode} - ${res.body}'); + } +}