Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/relic/test/router/relic_app_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ void main() {
);
});

test('Given a RelicApp with a route, '
'when calling lookupUri, '
'then it delegates to the underlying router', () {
Response handler(final Request req) => Response.ok();
final app = RelicApp()..get('/a/b', handler);

// The query and fragment are not part of the route path.
final hit = app.lookupUri(
Method.get,
Uri.parse('http://example.com/a/b?q=1#frag'),
);
expect(hit, isA<RouterMatch<Handler>>());
expect((hit as RouterMatch<Handler>).value, same(handler));

expect(
app.lookupUri(Method.post, Uri.parse('http://example.com/a/b')),
isA<MethodMiss<Handler>>(),
);
expect(
app.lookupUri(
Method.get,
Uri.parse('http://example.com/nope'),
backtrack: false,
),
isA<PathMiss<Handler>>(),
);
});

test('Given a RelicApp, '
'when calling run with adapter factory, '
'then it creates a RelicServer and mounts the handler', () async {
Expand Down
14 changes: 9 additions & 5 deletions packages/relic_core/lib/src/middleware/routing_middleware.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,15 @@ class _RoutingMiddlewareBuilder<T extends Object> {

Handler call(final Handler next) {
return (final req) async {
final path = NormalizedPath.fromUri(req.url);
final routingKey = useHostWhenRouting
? NormalizedPath.fromSegments([req.url.host, ...path.segments])
: path;
final result = _router.lookupPath(req.method, routingKey);
final result = useHostWhenRouting
? _router.lookupPath(
req.method,
NormalizedPath.fromSegments([
req.url.host,
...NormalizedPath.fromUri(req.url).segments,
]),
)
: _router.lookupUri(req.method, req.url);
switch (result) {
case MethodMiss():
return Response(
Expand Down
11 changes: 2 additions & 9 deletions packages/relic_core/lib/src/router/no_cache.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import 'cache.dart';

/// A no-op [Cache] implementation that never stores or retrieves values.
///
/// Useful for high-cardinality workloads where caching causes more overhead
/// than it saves (e.g., many unique dynamic paths like `/users/:id`).
///
/// Example:
/// ```dart
/// NormalizedPath.interned = NoCache();
/// ```
/// A no-op [Cache] that never stores or retrieves values, for opting out of
/// caching in high-cardinality workloads where it costs more than it saves.
final class NoCache<K, V> implements Cache<K, V> {
Comment thread
FXschwartz marked this conversation as resolved.
/// Creates a no-op cache.
const NoCache();
Expand Down
66 changes: 21 additions & 45 deletions packages/relic_core/lib/src/router/normalized_path.dart
Original file line number Diff line number Diff line change
@@ -1,37 +1,17 @@
import 'package:meta/meta.dart';

import 'cache.dart';
import 'lru_cache.dart';

/// Represents a URL path that has been normalized.
///
/// Normalization includes:
/// - Resolving `.` and `..` segments.
/// - Removing empty segments caused by multiple consecutive slashes.
/// - Ensuring the path starts with a `/`.
///
/// Instances created from a path string are interned using an LRU cache for
/// efficiency, so identical paths will often share the same object instance.
/// Segment-built instances ([fromSegments], [fromUri]) are never interned: a
/// string cache key cannot tell a separator inside a segment from a real one.
/// Equality compares segments, so this affects allocation only.
/// Equality and [hashCode] are derived from [segments], so paths with the same
/// segments are equal regardless of how they were built. Construction does no
/// caching.
@immutable
class NormalizedPath {
/// Cache of interned instances.
///
/// Defaults to an [LruCache] with 10,000 entries. Can be replaced with any
/// [Cache] implementation to tune caching behavior:
///
/// ```dart
/// // Disable caching for high-cardinality workloads
/// NormalizedPath.interned = NoCache();
///
/// // Use a larger cache
/// NormalizedPath.interned = LruCache(50000);
/// ```
static Cache<String, NormalizedPath> interned =
LruCache<String, NormalizedPath>(10000);

/// The individual segments of the normalized path.
/// For example, the path `/a/b/c` would have segments `['a', 'b', 'c']`.
final List<String> segments;
Expand All @@ -44,20 +24,12 @@ class NormalizedPath {

/// Creates a [NormalizedPath] from a given [path] string.
///
/// The provided [path] will be normalized by resolving `.` and `..` segments
/// and removing empty segments. The resulting [NormalizedPath] instance may be
/// retrieved from a cache if an identical normalized path has been created
/// recently.
factory NormalizedPath(final String path) {
var result = interned[path];
if (result == null) {
result = NormalizedPath._(_normalizeSegments(path.split('/')));
// intern for both normalized path and path
result = interned[result.path] ??= result;
interned[path] = result; // cache for original path as well
}
return result;
}
/// The provided [path] is split on `/` and normalized by resolving `.` and
/// `..` segments and removing empty ones. The path is not percent-decoded,
/// so an encoded separator such as `%2F` stays literal within its segment;
/// use [NormalizedPath.fromUri] to derive a path from a request.
factory NormalizedPath(final String path) =>
NormalizedPath._(_normalizeSegments(path.split('/')));

/// Creates a [NormalizedPath] from segments that have already been split.
///
Expand All @@ -71,14 +43,18 @@ class NormalizedPath {

/// Creates a [NormalizedPath] from the path of [url].
///
/// This is the correct way to derive a path from a request. It reads
/// [Uri.pathSegments], which splits on the separator and only then decodes
/// each segment, so an encoded separator such as `%2F` stays inside its
/// segment. Building from [Uri.path] instead would decode first and then
/// split, introducing separators that no proxy in front of the server ever
/// saw.
factory NormalizedPath.fromUri(final Uri url) =>
NormalizedPath.fromSegments(url.pathSegments);
/// [Uri.pathSegments] splits on the separator before decoding, so an encoded
/// separator (`%2F`) stays within its segment.
factory NormalizedPath.fromUri(final Uri url) {
final segments = url.pathSegments;
// Reuse the unmodifiable pathSegments if clean
for (final segment in segments) {
if (segment.isEmpty || segment == '.' || segment == '..') {
return NormalizedPath._(_normalizeSegments(segments));
}
}
return NormalizedPath._(segments);
}

/// Normalizes [segments] by resolving `.` and `..` and dropping empty ones.
static List<String> _normalizeSegments(final Iterable<String> segments) {
Expand Down
49 changes: 28 additions & 21 deletions packages/relic_core/lib/src/router/path_trie.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ sealed class _DynamicSegment<T> {
final class _Parameter<T> extends _DynamicSegment<T> {
final String name;

_Parameter(this.name);
/// The parameter [name] as a [Symbol], precomputed for use during lookup.
final Symbol symbol;

_Parameter(this.name) : symbol = Symbol(name);
}

final class _Wildcard<T> extends _DynamicSegment<T> {}
Expand Down Expand Up @@ -392,7 +395,13 @@ final class PathTrie<T extends Object> {
final bool backtrack = true,
}) {
return backtrack
? _lookupRecursive(_root, normalizedPath, 0, _root.map, const {})
? _lookupRecursive(
_root,
normalizedPath,
0,
_root.map,
<Symbol, String>{},
)
: _lookupIterative(normalizedPath);
}

Expand Down Expand Up @@ -432,17 +441,14 @@ final class PathTrie<T extends Object> {

final segment = segments[index];

TrieMatch<T>? next(_TrieNode<T> node, final T Function(T)? map) =>
_lookupRecursive(node, normalizedPath, index + 1, map, parameters);

// Try literal match first
final child = node.children[segment];
if (child != null) {
final newMap = _composeMap(currentMap, child.map);
final result = _lookupRecursive(
child,
normalizedPath,
index + 1,
newMap,
parameters,
);
final result = next(child, newMap);
if (result != null) return result;
// Fall through to try dynamic segment
}
Expand All @@ -452,9 +458,6 @@ final class PathTrie<T extends Object> {
if (dynamicSegment != null) {
final dynamicNode = dynamicSegment.node;
final newMap = _composeMap(currentMap, dynamicNode.map);
final newParams = dynamicSegment is _Parameter<T>
? {...parameters, Symbol(dynamicSegment.name): segment}
: parameters;

if (dynamicSegment is _Tail<T>) {
// Tail matches: check for value at this position
Expand All @@ -463,19 +466,23 @@ final class PathTrie<T extends Object> {
value = newMap?.call(value) ?? value;
return TrieMatch(
value,
newParams,
parameters,
normalizedPath.subPath(0, index),
normalizedPath.subPath(index),
);
}
} else if (dynamicSegment is _Parameter<T>) {
final prev = parameters[dynamicSegment.symbol];
parameters[dynamicSegment.symbol] = segment;
final result = next(dynamicNode, newMap);
if (result != null) return result;
if (prev == null) {
parameters.remove(dynamicSegment.symbol);
} else {
parameters[dynamicSegment.symbol] = prev;
}
} else {
return _lookupRecursive(
dynamicNode,
normalizedPath,
index + 1,
newMap,
newParams,
);
return next(dynamicNode, newMap);
}
}

Expand Down Expand Up @@ -524,7 +531,7 @@ final class PathTrie<T extends Object> {
currentNode = dynamicSegment.node;
updateMap();
if (dynamicSegment case final _Parameter<T> parameter) {
parameters[Symbol(parameter.name)] = segment;
parameters[parameter.symbol] = segment;
}
if (dynamicSegment is _Tail<T>) break; // possible early match
}
Expand Down
7 changes: 7 additions & 0 deletions packages/relic_core/lib/src/router/relic_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ final class RelicApp implements RelicRouter, _Reloadable {
final NormalizedPath normalizedPath, {
final bool backtrack = true,
}) => delegate.lookupPath(method, normalizedPath, backtrack: backtrack);

@override
LookupResult<Handler> lookupUri(
final Method method,
final Uri url, {
final bool backtrack = true,
}) => delegate.lookupUri(method, url, backtrack: backtrack);
}

/// Developer tools for inspecting and debugging a [RelicApp].
Expand Down
17 changes: 14 additions & 3 deletions packages/relic_core/lib/src/router/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ final class Router<T extends Object> {

/// Looks up a route matching an already normalized [normalizedPath].
///
/// Use this when the caller has built the path from parts and must not have
/// them re-split, such as when a routing key is assembled from a host and a
/// request path.
/// Use when the caller already holds a normalized path (e.g. a routing key
/// assembled from host and path). Walks the trie directly; results are not
/// cached.
LookupResult<T> lookupPath(
final Method method,
final NormalizedPath normalizedPath, {
Expand All @@ -182,6 +182,17 @@ final class Router<T extends Object> {
return RouterMatch(route, entry.parameters, entry.matched, entry.remaining);
}

/// Looks up a route for the [Uri] of a request.
///
/// The entry point for request routing: derives the path via
/// [NormalizedPath.fromUri] (splitting before decoding, so an encoded
/// separator cannot alter routing), then looks it up with [lookupPath].
LookupResult<T> lookupUri(
final Method method,
final Uri url, {
final bool backtrack = true,
}) => lookupPath(method, NormalizedPath.fromUri(url), backtrack: backtrack);

/// Returns true if the router has no routes.
bool get isEmpty => _allRoutes.isEmpty;

Expand Down
65 changes: 0 additions & 65 deletions packages/relic_core/test/router/no_cache_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,69 +27,4 @@ void main() {
expect(cache.length, equals(0));
});
});

group('Given NormalizedPath with NoCache', () {
late Cache<String, NormalizedPath> originalCache;

setUp(() {
originalCache = NormalizedPath.interned;
NormalizedPath.interned = const NoCache();
});

tearDown(() {
NormalizedPath.interned = originalCache;
});

test('when creating NormalizedPath '
'then normalization still works correctly', () {
final path = NormalizedPath('/a/b/c');
expect(path.segments, equals(['a', 'b', 'c']));
expect(path.toString(), equals('/a/b/c'));
});

test('when creating equivalent paths '
'then they are equal but not identical', () {
final path1 = NormalizedPath('/a/b');
final path2 = NormalizedPath('/a/b');
expect(path1, equals(path2));
expect(identical(path1, path2), isFalse);
});

test('when normalizing complex paths '
'then normalization is correct', () {
final path = NormalizedPath('/a/./b/../c');
expect(path.segments, equals(['a', 'c']));
expect(path.toString(), equals('/a/c'));
});
});

group('Given NormalizedPath with custom-sized LruCache', () {
late Cache<String, NormalizedPath> originalCache;

setUp(() {
originalCache = NormalizedPath.interned;
NormalizedPath.interned = LruCache<String, NormalizedPath>(2);
});

tearDown(() {
NormalizedPath.interned = originalCache;
});

test('when cache capacity is exceeded '
'then old entries are evicted', () {
final path1 = NormalizedPath('/a');
NormalizedPath('/b'); // fill cache
final path3 = NormalizedPath('/c');

// path1 should have been evicted from the small cache
final path1Again = NormalizedPath('/a');
expect(path1, equals(path1Again));
// With a cache of size 2, after /a, /b, /c, /a is evicted
// so creating /a again produces a new (non-identical) instance
expect(identical(path1, path1Again), isFalse);

// path3 and path2 should still be cached (or path3 and path1Again)
expect(identical(path3, NormalizedPath('/c')), isTrue);
});
});
}
Loading
Loading