| // 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 'package:flutter/widgets.dart'; |
| |
| import 'configuration.dart'; |
| import 'logging.dart'; |
| import 'match.dart'; |
| import 'misc/errors.dart'; |
| import 'path_utils.dart'; |
| import 'typedefs.dart'; |
| export 'route.dart'; |
| export 'state.dart'; |
| |
| /// The route configuration for GoRouter configured by the app. |
| class RouteConfiguration { |
| /// Constructs a [RouteConfiguration]. |
| RouteConfiguration({ |
| required this.routes, |
| required this.redirectLimit, |
| required this.topRedirect, |
| required this.navigatorKey, |
| }) : assert(_debugCheckPath(routes, true)), |
| assert( |
| _debugVerifyNoDuplicatePathParameter(routes, <String, GoRoute>{})), |
| assert(_debugCheckParentNavigatorKeys( |
| routes, <GlobalKey<NavigatorState>>[navigatorKey])) { |
| assert(_debugCheckStatefulShellBranchDefaultLocations(routes)); |
| _cacheNameToPath('', routes); |
| log.info(debugKnownRoutes()); |
| } |
| |
| static bool _debugCheckPath(List<RouteBase> routes, bool isTopLevel) { |
| for (final RouteBase route in routes) { |
| late bool subRouteIsTopLevel; |
| if (route is GoRoute) { |
| if (isTopLevel) { |
| if (!route.path.startsWith('/')) { |
| throw GoError('top-level path must start with "/": $route'); |
| } |
| } else { |
| if (route.path.startsWith('/') || route.path.endsWith('/')) { |
| throw GoError('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. |
| if (!allowedKeys.contains(parentKey)) { |
| throw GoError('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) { |
| if (allowedKeys.contains(branch.navigatorKey)) { |
| throw GoError( |
| '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; |
| if (initialLocation == null) { |
| throw GoError( |
| 'The default location of a StatefulShellBranch must be ' |
| 'derivable from GoRoute descendant'); |
| } |
| if (route!.pathParameters.isNotEmpty) { |
| throw GoError( |
| 'The default location of a StatefulShellBranch cannot be ' |
| 'a parameterized route'); |
| } |
| } else { |
| final RouteMatchList matchList = findMatch(branch.initialLocation!); |
| if (matchList.isError) { |
| throw GoError( |
| '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); |
| } |
| if (!matchFound) { |
| throw GoError( |
| '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, String errorMessage) { |
| final Exception error = Exception(errorMessage); |
| return RouteMatchList( |
| matches: const <RouteMatch>[], |
| error: error, |
| uri: uri, |
| pathParameters: const <String, String>{}, |
| ); |
| } |
| |
| /// The list of top level routes used by [GoRouterDelegate]. |
| final List<RouteBase> routes; |
| |
| /// The limit for the number of consecutive redirects. |
| final int redirectLimit; |
| |
| /// The global key for top level navigator. |
| final GlobalKey<NavigatorState> navigatorKey; |
| |
| /// Top level page redirect. |
| final GoRouterRedirect topRedirect; |
| |
| 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.info('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<RouteMatch>? matches = _getLocRouteMatches(uri, pathParameters); |
| |
| if (matches == null) { |
| return _errorRouteMatchList(uri, 'no routes for location: $uri'); |
| } |
| return RouteMatchList( |
| matches: matches, |
| uri: uri, |
| pathParameters: pathParameters, |
| extra: extra); |
| } |
| |
| List<RouteMatch>? _getLocRouteMatches( |
| Uri uri, Map<String, String> pathParameters) { |
| final List<RouteMatch>? result = _getLocRouteRecursively( |
| location: uri.path, |
| remainingLocation: uri.path, |
| matchedLocation: '', |
| pathParameters: pathParameters, |
| routes: routes, |
| ); |
| return result; |
| } |
| |
| List<RouteMatch>? _getLocRouteRecursively({ |
| required String location, |
| required String remainingLocation, |
| required String matchedLocation, |
| required Map<String, String> pathParameters, |
| required List<RouteBase> routes, |
| }) { |
| List<RouteMatch>? result; |
| late Map<String, String> subPathParameters; |
| // find the set of matches at this level of the tree |
| for (final RouteBase route in routes) { |
| subPathParameters = <String, String>{}; |
| |
| final RouteMatch? match = RouteMatch.match( |
| route: route, |
| remainingLocation: remainingLocation, |
| matchedLocation: matchedLocation, |
| pathParameters: subPathParameters, |
| ); |
| |
| if (match == null) { |
| continue; |
| } |
| |
| if (match.route is GoRoute && |
| match.matchedLocation.toLowerCase() == location.toLowerCase()) { |
| // If it is a complete match, then return the matched route |
| // NOTE: need a lower case match because matchedLocation is canonicalized to match |
| // the path case whereas the location can be of any case and still match |
| result = <RouteMatch>[match]; |
| } else if (route.routes.isEmpty) { |
| // If it is partial match but no sub-routes, bail. |
| continue; |
| } else { |
| // Otherwise, recurse |
| final String childRestLoc; |
| final String newParentSubLoc; |
| if (match.route is ShellRouteBase) { |
| childRestLoc = remainingLocation; |
| newParentSubLoc = matchedLocation; |
| } else { |
| assert(location.startsWith(match.matchedLocation)); |
| assert(remainingLocation.isNotEmpty); |
| |
| childRestLoc = location.substring(match.matchedLocation.length + |
| (match.matchedLocation == '/' ? 0 : 1)); |
| newParentSubLoc = match.matchedLocation; |
| } |
| |
| final List<RouteMatch>? subRouteMatch = _getLocRouteRecursively( |
| location: location, |
| remainingLocation: childRestLoc, |
| matchedLocation: newParentSubLoc, |
| pathParameters: subPathParameters, |
| routes: route.routes, |
| ); |
| |
| // If there's no sub-route matches, there is no match for this location |
| if (subRouteMatch == null) { |
| continue; |
| } |
| result = <RouteMatch>[match, ...subRouteMatch]; |
| } |
| // Should only reach here if there is a match. |
| break; |
| } |
| if (result != null) { |
| pathParameters.addAll(subPathParameters); |
| } |
| return result; |
| } |
| |
| /// 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 FutureOr<String?> routeLevelRedirectResult = |
| _getRouteLevelRedirect(context, prevMatchList, 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 = topRedirect( |
| context, |
| GoRouterState( |
| this, |
| location: prevLocation, |
| // No name available at the top level trim the query params off the |
| // sub-location to match route.redirect |
| fullPath: prevMatchList.fullPath, |
| pathParameters: prevMatchList.pathParameters, |
| matchedLocation: prevMatchList.uri.path, |
| queryParameters: prevMatchList.uri.queryParameters, |
| queryParametersAll: prevMatchList.uri.queryParametersAll, |
| extra: prevMatchList.extra, |
| pageKey: const ValueKey<String>('topLevel'), |
| ), |
| ); |
| |
| 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, |
| int currentCheckIndex, |
| ) { |
| if (currentCheckIndex >= matchList.matches.length) { |
| return null; |
| } |
| final RouteMatch match = matchList.matches[currentCheckIndex]; |
| FutureOr<String?> processRouteRedirect(String? newLocation) => |
| newLocation ?? |
| _getRouteLevelRedirect(context, matchList, currentCheckIndex + 1); |
| final RouteBase route = match.route; |
| FutureOr<String?> routeRedirectResult; |
| if (route is GoRoute && route.redirect != null) { |
| routeRedirectResult = route.redirect!( |
| context, |
| GoRouterState( |
| this, |
| location: matchList.uri.toString(), |
| matchedLocation: match.matchedLocation, |
| name: route.name, |
| path: route.path, |
| fullPath: matchList.fullPath, |
| extra: matchList.extra, |
| pathParameters: matchList.pathParameters, |
| queryParameters: matchList.uri.queryParameters, |
| queryParametersAll: matchList.uri.queryParametersAll, |
| pageKey: match.pageKey, |
| ), |
| ); |
| } |
| 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 RedirectionError catch (e) { |
| log.info('Redirection error: ${e.message}'); |
| return _errorRouteMatchList(e.location, e.message); |
| } |
| } |
| |
| /// 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 RedirectionError('redirect loop detected', |
| <RouteMatchList>[...redirects, newMatch], prevLocation); |
| } |
| if (redirects.length > redirectLimit) { |
| throw RedirectionError('too many redirects', |
| <RouteMatchList>[...redirects, newMatch], prevLocation); |
| } |
| |
| redirects.add(newMatch); |
| |
| log.info('redirecting to $newMatch'); |
| } |
| |
| /// 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, '', routes); |
| |
| @override |
| String toString() { |
| return 'RouterConfiguration: $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(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); |
| } |
| } |
| } |
| } |
| } |