blob: bef882e14605e8d52255b4bb1d4e13c7eeca6321 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'logging.dart';
import 'match.dart';
import 'misc/errors.dart';
import 'path_utils.dart';
import 'route.dart';
import 'router.dart';
import 'state.dart';
/// The signature of the redirect callback.
typedef GoRouterRedirect = FutureOr<String?> Function(
BuildContext context, GoRouterState state);
/// The route configuration for GoRouter configured by the app.
class RouteConfiguration {
/// Constructs a [RouteConfiguration].
RouteConfiguration(
this._routingConfig, {
required this.navigatorKey,
this.extraCodec,
}) {
_onRoutingTableChanged();
_routingConfig.addListener(_onRoutingTableChanged);
}
static bool _debugCheckPath(List<RouteBase> routes, bool isTopLevel) {
for (final RouteBase route in routes) {
late bool subRouteIsTopLevel;
if (route is GoRoute) {
if (isTopLevel) {
assert(route.path.startsWith('/'),
'top-level path must start with "/": $route');
} else {
assert(!route.path.startsWith('/') && !route.path.endsWith('/'),
'sub-route path may not start or end with "/": $route');
}
subRouteIsTopLevel = false;
} else if (route is ShellRouteBase) {
subRouteIsTopLevel = isTopLevel;
}
_debugCheckPath(route.routes, subRouteIsTopLevel);
}
return true;
}
// Check that each parentNavigatorKey refers to either a ShellRoute's
// navigatorKey or the root navigator key.
static bool _debugCheckParentNavigatorKeys(
List<RouteBase> routes, List<GlobalKey<NavigatorState>> allowedKeys) {
for (final RouteBase route in routes) {
if (route is GoRoute) {
final GlobalKey<NavigatorState>? parentKey = route.parentNavigatorKey;
if (parentKey != null) {
// Verify that the root navigator or a ShellRoute ancestor has a
// matching navigator key.
assert(
allowedKeys.contains(parentKey),
'parentNavigatorKey $parentKey must refer to'
" an ancestor ShellRoute's navigatorKey or GoRouter's"
' navigatorKey');
_debugCheckParentNavigatorKeys(
route.routes,
<GlobalKey<NavigatorState>>[
// Once a parentNavigatorKey is used, only that navigator key
// or keys above it can be used.
...allowedKeys.sublist(0, allowedKeys.indexOf(parentKey) + 1),
],
);
} else {
_debugCheckParentNavigatorKeys(
route.routes,
<GlobalKey<NavigatorState>>[
...allowedKeys,
],
);
}
} else if (route is ShellRoute) {
_debugCheckParentNavigatorKeys(
route.routes,
<GlobalKey<NavigatorState>>[...allowedKeys..add(route.navigatorKey)],
);
} else if (route is StatefulShellRoute) {
for (final StatefulShellBranch branch in route.branches) {
assert(
!allowedKeys.contains(branch.navigatorKey),
'StatefulShellBranch must not reuse an ancestor navigatorKey '
'(${branch.navigatorKey})');
_debugCheckParentNavigatorKeys(
branch.routes,
<GlobalKey<NavigatorState>>[
...allowedKeys,
branch.navigatorKey,
],
);
}
}
}
return true;
}
static bool _debugVerifyNoDuplicatePathParameter(
List<RouteBase> routes, Map<String, GoRoute> usedPathParams) {
for (final RouteBase route in routes) {
if (route is! GoRoute) {
continue;
}
for (final String pathParam in route.pathParameters) {
if (usedPathParams.containsKey(pathParam)) {
final bool sameRoute = usedPathParams[pathParam] == route;
throw GoError(
"duplicate path parameter, '$pathParam' found in ${sameRoute ? '$route' : '${usedPathParams[pathParam]}, and $route'}");
}
usedPathParams[pathParam] = route;
}
_debugVerifyNoDuplicatePathParameter(route.routes, usedPathParams);
route.pathParameters.forEach(usedPathParams.remove);
}
return true;
}
// Check to see that the configured initialLocation of StatefulShellBranches
// points to a descendant route of the route branch.
bool _debugCheckStatefulShellBranchDefaultLocations(List<RouteBase> routes) {
for (final RouteBase route in routes) {
if (route is StatefulShellRoute) {
for (final StatefulShellBranch branch in route.branches) {
if (branch.initialLocation == null) {
// Recursively search for the first GoRoute descendant. Will
// throw assertion error if not found.
final GoRoute? route = branch.defaultRoute;
final String? initialLocation =
route != null ? locationForRoute(route) : null;
assert(
initialLocation != null,
'The default location of a StatefulShellBranch must be '
'derivable from GoRoute descendant');
assert(
route!.pathParameters.isEmpty,
'The default location of a StatefulShellBranch cannot be '
'a parameterized route');
} else {
final RouteMatchList matchList = findMatch(branch.initialLocation!);
assert(
!matchList.isError,
'initialLocation (${matchList.uri}) of StatefulShellBranch must '
'be a valid location');
final List<RouteBase> matchRoutes = matchList.routes;
final int shellIndex = matchRoutes.indexOf(route);
bool matchFound = false;
if (shellIndex >= 0 && (shellIndex + 1) < matchRoutes.length) {
final RouteBase branchRoot = matchRoutes[shellIndex + 1];
matchFound = branch.routes.contains(branchRoot);
}
assert(
matchFound,
'The initialLocation (${branch.initialLocation}) of '
'StatefulShellBranch must match a descendant route of the '
'branch');
}
}
}
_debugCheckStatefulShellBranchDefaultLocations(route.routes);
}
return true;
}
/// The match used when there is an error during parsing.
static RouteMatchList _errorRouteMatchList(
Uri uri,
GoException exception, {
Object? extra,
}) {
return RouteMatchList(
matches: const <RouteMatch>[],
extra: extra,
error: exception,
uri: uri,
pathParameters: const <String, String>{},
);
}
void _onRoutingTableChanged() {
final RoutingConfig routingTable = _routingConfig.value;
assert(_debugCheckPath(routingTable.routes, true));
assert(_debugVerifyNoDuplicatePathParameter(
routingTable.routes, <String, GoRoute>{}));
assert(_debugCheckParentNavigatorKeys(
routingTable.routes, <GlobalKey<NavigatorState>>[navigatorKey]));
assert(_debugCheckStatefulShellBranchDefaultLocations(routingTable.routes));
_nameToPath.clear();
_cacheNameToPath('', routingTable.routes);
log(debugKnownRoutes());
}
/// Builds a [GoRouterState] suitable for top level callback such as
/// `GoRouter.redirect` or `GoRouter.onException`.
GoRouterState buildTopLevelGoRouterState(RouteMatchList matchList) {
return GoRouterState(
this,
uri: matchList.uri,
// No name available at the top level trim the query params off the
// sub-location to match route.redirect
fullPath: matchList.fullPath,
pathParameters: matchList.pathParameters,
matchedLocation: matchList.uri.path,
extra: matchList.extra,
pageKey: const ValueKey<String>('topLevel'),
topRoute: matchList.lastOrNull?.route,
);
}
/// The routing table.
final ValueListenable<RoutingConfig> _routingConfig;
/// The list of top level routes used by [GoRouterDelegate].
List<RouteBase> get routes => _routingConfig.value.routes;
/// Top level page redirect.
GoRouterRedirect get topRedirect => _routingConfig.value.redirect;
/// The limit for the number of consecutive redirects.
int get redirectLimit => _routingConfig.value.redirectLimit;
/// The global key for top level navigator.
final GlobalKey<NavigatorState> navigatorKey;
/// The codec used to encode and decode extra into a serializable format.
///
/// When navigating using [GoRouter.go] or [GoRouter.push], one can provide
/// an `extra` parameter along with it. If the extra contains complex data,
/// consider provide a codec for serializing and deserializing the extra data.
///
/// See also:
/// * [Navigation](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html)
/// topic.
/// * [extra_codec](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart)
/// example.
final Codec<Object?, Object?>? extraCodec;
final Map<String, String> _nameToPath = <String, String>{};
/// Looks up the url location by a [GoRoute]'s name.
String namedLocation(
String name, {
Map<String, String> pathParameters = const <String, String>{},
Map<String, dynamic> queryParameters = const <String, dynamic>{},
}) {
assert(() {
log('getting location for name: '
'"$name"'
'${pathParameters.isEmpty ? '' : ', pathParameters: $pathParameters'}'
'${queryParameters.isEmpty ? '' : ', queryParameters: $queryParameters'}');
return true;
}());
assert(_nameToPath.containsKey(name), 'unknown route name: $name');
final String path = _nameToPath[name]!;
assert(() {
// Check that all required params are present
final List<String> paramNames = <String>[];
patternToRegExp(path, paramNames);
for (final String paramName in paramNames) {
assert(pathParameters.containsKey(paramName),
'missing param "$paramName" for $path');
}
// Check that there are no extra params
for (final String key in pathParameters.keys) {
assert(paramNames.contains(key), 'unknown param "$key" for $path');
}
return true;
}());
final Map<String, String> encodedParams = <String, String>{
for (final MapEntry<String, String> param in pathParameters.entries)
param.key: Uri.encodeComponent(param.value)
};
final String location = patternToPath(path, encodedParams);
return Uri(
path: location,
queryParameters: queryParameters.isEmpty ? null : queryParameters)
.toString();
}
/// Finds the routes that matched the given URL.
RouteMatchList findMatch(String location, {Object? extra}) {
final Uri uri = Uri.parse(canonicalUri(location));
final Map<String, String> pathParameters = <String, String>{};
final List<RouteMatchBase> matches =
_getLocRouteMatches(uri, pathParameters);
if (matches.isEmpty) {
return _errorRouteMatchList(
uri,
GoException('no routes for location: $uri'),
extra: extra,
);
}
return RouteMatchList(
matches: matches,
uri: uri,
pathParameters: pathParameters,
extra: extra);
}
/// Reparse the input RouteMatchList
RouteMatchList reparse(RouteMatchList matchList) {
RouteMatchList result =
findMatch(matchList.uri.toString(), extra: matchList.extra);
for (final ImperativeRouteMatch imperativeMatch
in matchList.matches.whereType<ImperativeRouteMatch>()) {
final ImperativeRouteMatch match = ImperativeRouteMatch(
pageKey: imperativeMatch.pageKey,
matches: findMatch(imperativeMatch.matches.uri.toString(),
extra: imperativeMatch.matches.extra),
completer: imperativeMatch.completer);
result = result.push(match);
}
return result;
}
List<RouteMatchBase> _getLocRouteMatches(
Uri uri, Map<String, String> pathParameters) {
for (final RouteBase route in _routingConfig.value.routes) {
final List<RouteMatchBase> result = RouteMatchBase.match(
rootNavigatorKey: navigatorKey,
route: route,
uri: uri,
pathParameters: pathParameters,
);
if (result.isNotEmpty) {
return result;
}
}
return const <RouteMatchBase>[];
}
/// Processes redirects by returning a new [RouteMatchList] representing the new
/// location.
FutureOr<RouteMatchList> redirect(
BuildContext context, FutureOr<RouteMatchList> prevMatchListFuture,
{required List<RouteMatchList> redirectHistory}) {
FutureOr<RouteMatchList> processRedirect(RouteMatchList prevMatchList) {
final String prevLocation = prevMatchList.uri.toString();
FutureOr<RouteMatchList> processTopLevelRedirect(
String? topRedirectLocation) {
if (topRedirectLocation != null &&
topRedirectLocation != prevLocation) {
final RouteMatchList newMatch = _getNewMatches(
topRedirectLocation,
prevMatchList.uri,
redirectHistory,
);
if (newMatch.isError) {
return newMatch;
}
return redirect(
context,
newMatch,
redirectHistory: redirectHistory,
);
}
FutureOr<RouteMatchList> processRouteLevelRedirect(
String? routeRedirectLocation) {
if (routeRedirectLocation != null &&
routeRedirectLocation != prevLocation) {
final RouteMatchList newMatch = _getNewMatches(
routeRedirectLocation,
prevMatchList.uri,
redirectHistory,
);
if (newMatch.isError) {
return newMatch;
}
return redirect(
context,
newMatch,
redirectHistory: redirectHistory,
);
}
return prevMatchList;
}
final List<RouteMatchBase> routeMatches = <RouteMatchBase>[];
prevMatchList.visitRouteMatches((RouteMatchBase match) {
if (match.route.redirect != null) {
routeMatches.add(match);
}
return true;
});
final FutureOr<String?> routeLevelRedirectResult =
_getRouteLevelRedirect(context, prevMatchList, routeMatches, 0);
if (routeLevelRedirectResult is String?) {
return processRouteLevelRedirect(routeLevelRedirectResult);
}
return routeLevelRedirectResult
.then<RouteMatchList>(processRouteLevelRedirect);
}
redirectHistory.add(prevMatchList);
// Check for top-level redirect
final FutureOr<String?> topRedirectResult = _routingConfig.value.redirect(
context,
buildTopLevelGoRouterState(prevMatchList),
);
if (topRedirectResult is String?) {
return processTopLevelRedirect(topRedirectResult);
}
return topRedirectResult.then<RouteMatchList>(processTopLevelRedirect);
}
if (prevMatchListFuture is RouteMatchList) {
return processRedirect(prevMatchListFuture);
}
return prevMatchListFuture.then<RouteMatchList>(processRedirect);
}
FutureOr<String?> _getRouteLevelRedirect(
BuildContext context,
RouteMatchList matchList,
List<RouteMatchBase> routeMatches,
int currentCheckIndex,
) {
if (currentCheckIndex >= routeMatches.length) {
return null;
}
final RouteMatchBase match = routeMatches[currentCheckIndex];
FutureOr<String?> processRouteRedirect(String? newLocation) =>
newLocation ??
_getRouteLevelRedirect(
context, matchList, routeMatches, currentCheckIndex + 1);
final RouteBase route = match.route;
final FutureOr<String?> routeRedirectResult = route.redirect!.call(
context,
match.buildState(this, matchList),
);
if (routeRedirectResult is String?) {
return processRouteRedirect(routeRedirectResult);
}
return routeRedirectResult.then<String?>(processRouteRedirect);
}
RouteMatchList _getNewMatches(
String newLocation,
Uri previousLocation,
List<RouteMatchList> redirectHistory,
) {
try {
final RouteMatchList newMatch = findMatch(newLocation);
_addRedirect(redirectHistory, newMatch, previousLocation);
return newMatch;
} on GoException catch (e) {
log('Redirection exception: ${e.message}');
return _errorRouteMatchList(previousLocation, e);
}
}
/// Adds the redirect to [redirects] if it is valid.
///
/// Throws if a loop is detected or the redirection limit is reached.
void _addRedirect(
List<RouteMatchList> redirects,
RouteMatchList newMatch,
Uri prevLocation,
) {
if (redirects.contains(newMatch)) {
throw GoException(
'redirect loop detected ${_formatRedirectionHistory(<RouteMatchList>[
...redirects,
newMatch
])}');
}
if (redirects.length > _routingConfig.value.redirectLimit) {
throw GoException(
'too many redirects ${_formatRedirectionHistory(<RouteMatchList>[
...redirects,
newMatch
])}');
}
redirects.add(newMatch);
log('redirecting to $newMatch');
}
String _formatRedirectionHistory(List<RouteMatchList> redirections) {
return redirections
.map<String>(
(RouteMatchList routeMatches) => routeMatches.uri.toString())
.join(' => ');
}
/// Get the location for the provided route.
///
/// Builds the absolute path for the route, by concatenating the paths of the
/// route and all its ancestors.
String? locationForRoute(RouteBase route) =>
fullPathForRoute(route, '', _routingConfig.value.routes);
@override
String toString() {
return 'RouterConfiguration: ${_routingConfig.value.routes}';
}
/// Returns the full path of [routes].
///
/// Each path is indented based depth of the hierarchy, and its `name`
/// is also appended if not null
@visibleForTesting
String debugKnownRoutes() {
final StringBuffer sb = StringBuffer();
sb.writeln('Full paths for routes:');
_debugFullPathsFor(_routingConfig.value.routes, '', 0, sb);
if (_nameToPath.isNotEmpty) {
sb.writeln('known full paths for route names:');
for (final MapEntry<String, String> e in _nameToPath.entries) {
sb.writeln(' ${e.key} => ${e.value}');
}
}
return sb.toString();
}
void _debugFullPathsFor(List<RouteBase> routes, String parentFullpath,
int depth, StringBuffer sb) {
for (final RouteBase route in routes) {
if (route is GoRoute) {
final String fullPath = concatenatePaths(parentFullpath, route.path);
sb.writeln(' => ${''.padLeft(depth * 2)}$fullPath');
_debugFullPathsFor(route.routes, fullPath, depth + 1, sb);
} else if (route is ShellRouteBase) {
_debugFullPathsFor(route.routes, parentFullpath, depth, sb);
}
}
}
void _cacheNameToPath(String parentFullPath, List<RouteBase> childRoutes) {
for (final RouteBase route in childRoutes) {
if (route is GoRoute) {
final String fullPath = concatenatePaths(parentFullPath, route.path);
if (route.name != null) {
final String name = route.name!;
assert(
!_nameToPath.containsKey(name),
'duplication fullpaths for name '
'"$name":${_nameToPath[name]}, $fullPath');
_nameToPath[name] = fullPath;
}
if (route.routes.isNotEmpty) {
_cacheNameToPath(fullPath, route.routes);
}
} else if (route is ShellRouteBase) {
if (route.routes.isNotEmpty) {
_cacheNameToPath(parentFullPath, route.routes);
}
}
}
}
}