Skip to content
Open
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
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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is NoCache still needed? I couldn't find any consumers now that NormalizedPath.interned is gone, so there doesn't seem to be anything to plug it into.

/// 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
99 changes: 56 additions & 43 deletions packages/relic_core/lib/src/router/path_trie.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ final class _TrieNode<T> {

/// Indicates if this node holds a single value
bool get isSingle => _hasNoChildren && value != null;

/// True if this node has both a literal child and a dynamic segment, so a
/// lookup here may have to backtrack from the literal to the dynamic branch.
bool get _isAmbiguous => children.isNotEmpty && dynamicSegment != null;
}

sealed class _DynamicSegment<T> {
Expand All @@ -60,7 +64,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 All @@ -75,6 +82,14 @@ final class PathTrie<T extends Object> {
// Note: not final since we update in attach
var _root = _TrieNode<T>();

/// True if any node has both literal children and a dynamic segment, the only
/// case where a lookup can match a literal, fail deeper, and need the dynamic
/// branch. Set during registration and never cleared, so it may over-report.
bool _needsBacktracking = false;

/// Whether lookups on this trie require backtracking. See [_needsBacktracking].
bool get needsBacktracking => _needsBacktracking;

/// Adds a path and its associated value to the trie.
///
/// The [normalizedPath] is expected to be pre-normalized (e.g., using
Expand Down Expand Up @@ -258,7 +273,8 @@ final class PathTrie<T extends Object> {

for (int i = 0; i < segments.length; i++) {
final segment = segments[i];
final dynamicSegment = currentNode.dynamicSegment;
final node = currentNode; // node this segment is added to
final dynamicSegment = node.dynamicSegment;

if (segment.startsWith('**')) {
// Handle tail segment
Expand Down Expand Up @@ -312,6 +328,8 @@ final class PathTrie<T extends Object> {
() => _TrieNode<T>(),
);
}

if (node._isAmbiguous) _needsBacktracking = true;
}
return currentNode;
}
Expand Down Expand Up @@ -376,23 +394,35 @@ final class PathTrie<T extends Object> {
: (final v) => parentMap(childMap(v));
}
currentNode.children.addAll(node.children);

_needsBacktracking =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran into what looks like a stale-flag case with group(). Since attach(consume: false) shares the subtree, routes added to the sub router afterwards mutate the parent's nodes but only seem to set the sub trie's flag:

final sub = router.group('/api');
sub.get('/users/x', 1);
sub.get('/users/:id/y', 2);
router.lookup(Method.get, '/api/users/x/y'); // PathMiss — matches {id: x} on main

injectAt looks fine since it consumes. Maybe the flag needs to live with the shared structure rather than the PathTrie wrapper? Not sure what the cleanest fix is. Could use a regression test too, the backtrack tests cover the happy paths but not this one.

_needsBacktracking ||
trie._needsBacktracking ||
currentNode._isAmbiguous;

trie._root = consume ? _TrieNode() : currentNode;
}

/// Looks up a [normalizedPath] in the trie and extracts parameters.
///
/// Literal segments are prioritized over parameters during matching.
/// If [backtrack] is set (default), then the search is allowed to use
/// backtracking.
/// Literal segments are prioritized over parameters. Backtracking runs only
/// when [backtrack] is true and the table [needsBacktracking]; otherwise a
/// faster non-backtracking walk is used. Passing `backtrack: false` lets a
/// literal shadow an overlapping parameter at the same level.
///
/// Returns a [TrieMatch] containing the associated value and extracted
/// parameters if a matching path is found, otherwise returns `null`.
/// Returns a [TrieMatch] if a matching path is found, otherwise `null`.
TrieMatch<T>? lookup(
final NormalizedPath normalizedPath, {
final bool backtrack = true,
}) {
return backtrack
? _lookupRecursive(_root, normalizedPath, 0, _root.map, const {})
return backtrack && _needsBacktracking
? _lookupRecursive(
_root,
normalizedPath,
0,
_root.map,
<Symbol, String>{},
)
: _lookupIterative(normalizedPath);
}

Expand Down Expand Up @@ -432,17 +462,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 +479,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 +487,18 @@ 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>) {
parameters[dynamicSegment.symbol] = segment;
final result = next(dynamicNode, newMap);
if (result != null) return result;
parameters.remove(dynamicSegment.symbol); // backtrack

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this drops the outer binding when the same param name is bound at two levels and the inner branch fails:

final trie = PathTrie<int>()
  ..add(NormalizedPath('/:x/lit/:x/a'), 1)
  ..add(NormalizedPath('/:x/:y/c'), 2);
trie.lookup(NormalizedPath('/v/lit/c')); // params = {y: lit} — x is gone

On main this returns {x: v, y: lit} since the per-level copy kept the outer value. Probably a good place to add a test as well.

} else {
return _lookupRecursive(
dynamicNode,
normalizedPath,
index + 1,
newMap,
newParams,
);
return next(dynamicNode, newMap);
}
}

Expand All @@ -492,39 +515,30 @@ final class PathTrie<T extends Object> {
return (final v) => outer(inner(v));
}

// coverage:ignore-start
// ignore: unused_element
/// Non-backtracking lookup, valid only when [needsBacktracking] is false:
/// commits to a literal match if present, else takes the single dynamic branch.
TrieMatch<T>? _lookupIterative(final NormalizedPath normalizedPath) {
final segments = normalizedPath.segments;
final parameters = <Symbol, String>{};

var currentNode = _root;
var currentMap = currentNode.map;

// Helper function to update combinedMap when descending the trie
void updateMap() {
final cm = currentMap;
final m = currentNode.map;
currentMap = cm == null
? m // may also be null
: (m == null ? cm : (final v) => cm(m(v))); // compose map function
}

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;
updateMap();
currentMap = _composeMap(currentMap, currentNode.map);
} else {
final dynamicSegment = currentNode.dynamicSegment;
if (dynamicSegment == null) return null; // no match
currentNode = dynamicSegment.node;
updateMap();
currentMap = _composeMap(currentMap, currentNode.map);
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 All @@ -542,15 +556,14 @@ final class PathTrie<T extends Object> {
if (dynamicSegment is _Tail<T>) {
currentNode = dynamicSegment.node;
value = currentNode.value;
updateMap();
currentMap = _composeMap(currentMap, currentNode.map);
}
}

if (value == null) return null;
value = currentMap?.call(value) ?? value;
return TrieMatch(value, parameters, matchedPath, remainingPath);
}
// coverage:ignore-end

/// Returns true if the path trie has no routes.
bool get isEmpty => _root.isEmpty;
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
Loading
Loading