blob: 4fc114999b792139568932f7e5d412c2b6cd1d65 [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 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/src/go_route_information_provider.dart';
import 'go_route.dart';
import 'go_route_match.dart';
import 'go_router_state.dart';
import 'logging.dart';
import 'path_parser.dart';
import 'typedefs.dart';
class _ParserError extends Error implements UnsupportedError {
_ParserError(this.message);
@override
final String? message;
}
/// GoRouter implementation of the RouteInformationParser base class
class GoRouteInformationParser
extends RouteInformationParser<List<GoRouteMatch>> {
/// Creates a [GoRouteInformationParser].
GoRouteInformationParser({
required this.routes,
required this.redirectLimit,
required this.topRedirect,
this.debugRequireGoRouteInformationProvider = false,
}) : assert(() {
// check top-level route paths are valid
for (final GoRoute route in routes) {
assert(route.path.startsWith('/'),
'top-level path must start with "/": ${route.path}');
}
return true;
}()) {
_cacheNameToPath('', routes);
assert(() {
_debugLogKnownRoutes();
return true;
}());
}
/// List of top level routes used by the go router delegate.
final List<GoRoute> routes;
/// The limit for the number of consecutive redirects.
final int redirectLimit;
/// Top level page redirect.
final GoRouterRedirect topRedirect;
/// A debug property to assert [GoRouteInformationProvider] is in use along
/// with this parser.
///
/// An assertion error will be thrown if this property set to true and the
/// [GoRouteInformationProvider] is not in use.
///
/// Defaults to false.
final bool debugRequireGoRouteInformationProvider;
final Map<String, String> _nameToPath = <String, String>{};
void _cacheNameToPath(String parentFullPath, List<GoRoute> childRoutes) {
for (final GoRoute route in childRoutes) {
final String fullPath = concatenatePaths(parentFullPath, route.path);
if (route.name != null) {
final String name = route.name!.toLowerCase();
assert(!_nameToPath.containsKey(name),
'duplication fullpaths for name "$name":${_nameToPath[name]}, $fullPath');
_nameToPath[name] = fullPath;
}
if (route.routes.isNotEmpty) {
_cacheNameToPath(fullPath, route.routes);
}
}
}
/// Looks up the url location by a [GoRoute]'s name.
String namedLocation(
String name, {
Map<String, String> params = const <String, String>{},
Map<String, String> queryParams = const <String, String>{},
}) {
assert(() {
log.info('getting location for name: '
'"$name"'
'${params.isEmpty ? '' : ', params: $params'}'
'${queryParams.isEmpty ? '' : ', queryParams: $queryParams'}');
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(params.containsKey(paramName),
'missing param "$paramName" for $path');
}
// Check that there are no extra params
for (final String key in params.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 params.entries)
param.key: Uri.encodeComponent(param.value)
};
final String location = patternToPath(path, encodedParams);
return Uri(path: location, queryParameters: queryParams).toString();
}
/// Concatenates two paths.
///
/// e.g: pathA = /a, pathB = c/d, concatenatePaths(pathA, pathB) = /a/c/d.
static String concatenatePaths(String parentPath, String childPath) {
// at the root, just return the path
if (parentPath.isEmpty) {
assert(childPath.startsWith('/'));
assert(childPath == '/' || !childPath.endsWith('/'));
return childPath;
}
// not at the root, so append the parent path
assert(childPath.isNotEmpty);
assert(!childPath.startsWith('/'));
assert(!childPath.endsWith('/'));
return '${parentPath == '/' ? '' : parentPath}/$childPath';
}
/// for use by the Router architecture as part of the RouteInformationParser
@override
Future<List<GoRouteMatch>> parseRouteInformation(
RouteInformation routeInformation,
) {
assert(() {
if (debugRequireGoRouteInformationProvider) {
assert(
routeInformation is DebugGoRouteInformation,
'This GoRouteInformationParser needs to be used with '
'GoRouteInformationProvider, did you forget to pass in '
'GoRouter.routeInformationProvider to the Router constructor?');
}
return true;
}());
final List<GoRouteMatch> matches =
_getLocRouteMatchesWithRedirects(routeInformation);
// Use [SynchronousFuture] so that the initial url is processed
// synchronously and remove unwanted initial animations on deep-linking
return SynchronousFuture<List<GoRouteMatch>>(matches);
}
List<GoRouteMatch> _getLocRouteMatchesWithRedirects(
RouteInformation routeInformation) {
// start redirecting from the initial location
List<GoRouteMatch> matches;
final String location = routeInformation.location!;
try {
// watch redirects for loops
final List<String> redirects = <String>[_canonicalUri(location)];
bool redirected(String? redir) {
if (redir == null) {
return false;
}
assert(() {
if (Uri.tryParse(redir) == null) {
throw _ParserError('invalid redirect: $redir');
}
if (redirects.contains(redir)) {
throw _ParserError('redirect loop detected: ${<String>[
...redirects,
redir
].join(' => ')}');
}
if (redirects.length > redirectLimit) {
throw _ParserError('too many redirects: ${<String>[
...redirects,
redir
].join(' => ')}');
}
return true;
}());
redirects.add(redir);
assert(() {
log.info('redirecting to $redir');
return true;
}());
return true;
}
// keep looping till we're done redirecting
while (true) {
final String loc = redirects.last;
// check for top-level redirect
final Uri uri = Uri.parse(loc);
if (redirected(
topRedirect(
GoRouterState(
this,
location: loc,
name: null, // no name available at the top level
// trim the query params off the subloc to match route.redirect
subloc: uri.path,
// pass along the query params 'cuz that's all we have right now
queryParams: uri.queryParameters,
extra: routeInformation.state,
),
),
)) {
continue;
}
// get stack of route matches
matches = _getLocRouteMatches(loc, routeInformation.state);
// merge new params to keep params from previously matched paths, e.g.
// /family/:fid/person/:pid provides fid and pid to person/:pid
Map<String, String> previouslyMatchedParams = <String, String>{};
for (final GoRouteMatch match in matches) {
assert(
!previouslyMatchedParams.keys.any(match.encodedParams.containsKey),
'Duplicated parameter names',
);
match.encodedParams.addAll(previouslyMatchedParams);
previouslyMatchedParams = match.encodedParams;
}
// check top route for redirect
final GoRouteMatch top = matches.last;
if (redirected(
top.route.redirect(
GoRouterState(
this,
location: loc,
subloc: top.subloc,
name: top.route.name,
path: top.route.path,
fullpath: top.fullpath,
params: top.decodedParams,
queryParams: top.queryParams,
),
),
)) {
continue;
}
// no more redirects!
break;
}
// note that we need to catch it this way to get all the info, e.g. the
// file/line info for an error in an inline function impl, e.g. an inline
// `redirect` impl
// ignore: avoid_catches_without_on_clauses
} on _ParserError catch (err) {
// create a match that routes to the error page
final Exception error = Exception(err.message);
final Uri uri = Uri.parse(location);
matches = <GoRouteMatch>[
GoRouteMatch(
subloc: uri.path,
fullpath: uri.path,
encodedParams: <String, String>{},
queryParams: uri.queryParameters,
extra: null,
error: error,
route: GoRoute(
path: location,
pageBuilder: (BuildContext context, GoRouterState state) {
throw UnimplementedError();
}),
),
];
}
assert(matches.isNotEmpty);
return matches;
}
List<GoRouteMatch> _getLocRouteMatches(String location, Object? extra) {
final Uri uri = Uri.parse(location);
final List<GoRouteMatch> result = _getLocRouteRecursively(
loc: uri.path,
restLoc: uri.path,
routes: routes,
parentFullpath: '',
parentSubloc: '',
queryParams: uri.queryParameters,
extra: extra,
);
if (result.isEmpty) {
throw _ParserError('no routes for location: $location');
}
return result;
}
static List<GoRouteMatch> _getLocRouteRecursively({
required String loc,
required String restLoc,
required String parentSubloc,
required List<GoRoute> routes,
required String parentFullpath,
required Map<String, String> queryParams,
required Object? extra,
}) {
bool debugGatherAllMatches = false;
assert(() {
debugGatherAllMatches = true;
return true;
}());
final List<List<GoRouteMatch>> result = <List<GoRouteMatch>>[];
// find the set of matches at this level of the tree
for (final GoRoute route in routes) {
final String fullpath = concatenatePaths(parentFullpath, route.path);
final GoRouteMatch? match = GoRouteMatch.match(
route: route,
restLoc: restLoc,
parentSubloc: parentSubloc,
fullpath: fullpath,
queryParams: queryParams,
extra: extra,
);
if (match == null) {
continue;
}
if (match.subloc.toLowerCase() == loc.toLowerCase()) {
// If it is a complete match, then return the matched route
// NOTE: need a lower case match because subloc is canonicalized to match
// the path case whereas the location can be of any case and still match
result.add(<GoRouteMatch>[match]);
} else if (route.routes.isEmpty) {
// If it is partial match but no sub-routes, bail.
continue;
} else {
// otherwise recurse
final String childRestLoc =
loc.substring(match.subloc.length + (match.subloc == '/' ? 0 : 1));
assert(loc.startsWith(match.subloc));
assert(restLoc.isNotEmpty);
final List<GoRouteMatch> subRouteMatch = _getLocRouteRecursively(
loc: loc,
restLoc: childRestLoc,
parentSubloc: match.subloc,
routes: route.routes,
parentFullpath: fullpath,
queryParams: queryParams,
extra: extra,
).toList();
// if there's no sub-route matches, there is no match for this
// location
if (subRouteMatch.isEmpty) {
continue;
}
result.add(<GoRouteMatch>[match, ...subRouteMatch]);
}
// Should only reach here if there is a match.
if (debugGatherAllMatches) {
continue;
} else {
break;
}
}
if (result.isEmpty) {
return <GoRouteMatch>[];
}
// If there are multiple routes that match the location, returning the first one.
// To make predefined routes to take precedence over dynamic routes eg. '/:id'
// consider adding the dynamic route at the end of the routes
return result.first;
}
void _debugLogKnownRoutes() {
log.info('known full paths for routes:');
_debugLogFullPathsFor(routes, '', 0);
if (_nameToPath.isNotEmpty) {
log.info('known full paths for route names:');
for (final MapEntry<String, String> e in _nameToPath.entries) {
log.info(' ${e.key} => ${e.value}');
}
}
}
void _debugLogFullPathsFor(
List<GoRoute> routes,
String parentFullpath,
int depth,
) {
for (final GoRoute route in routes) {
final String fullpath = concatenatePaths(parentFullpath, route.path);
assert(() {
log.info(' => ${''.padLeft(depth * 2)}$fullpath');
return true;
}());
_debugLogFullPathsFor(route.routes, fullpath, depth + 1);
}
}
/// for use by the Router architecture as part of the RouteInformationParser
@override
RouteInformation restoreRouteInformation(List<GoRouteMatch> configuration) {
return RouteInformation(
location: configuration.last.fullUriString,
state: configuration.last.extra);
}
}
/// Normalizes the location string.
String _canonicalUri(String loc) {
String canon = Uri.parse(loc).toString();
canon = canon.endsWith('?') ? canon.substring(0, canon.length - 1) : canon;
// remove trailing slash except for when you shouldn't, e.g.
// /profile/ => /profile
// / => /
// /login?from=/ => login?from=/
canon = canon.endsWith('/') && canon != '/' && !canon.contains('?')
? canon.substring(0, canon.length - 1)
: canon;
// /login/?from=/ => /login?from=/
// /?from=/ => /?from=/
canon = canon.replaceFirst('/?', '?', 1);
return canon;
}