diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 3c58930709..9e14c9a5fb 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -57,7 +57,8 @@ import { routeParentCollector, routePathCollector, } from '../routing/collectors'; -import { RoutingProvider, validateRoutes } from '../routing/hooks'; +import { RoutingProvider } from '../routing/hooks'; +import { validateRoutes } from '../routing/validation'; import { AppContextProvider } from './AppContext'; import { AppIdentity } from './AppIdentity'; import { AppThemeProvider } from './AppThemeProvider'; diff --git a/packages/core-api/src/routing/RouteResolver.ts b/packages/core-api/src/routing/RouteResolver.ts new file mode 100644 index 0000000000..511d6bce2e --- /dev/null +++ b/packages/core-api/src/routing/RouteResolver.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; +import { + AnyRouteRef, + BackstageRouteObject, + RouteRef, + ExternalRouteRef, + AnyParams, + SubRouteRef, + routeRefType, + RouteFunc, +} from './types'; +import { isRouteRef } from './RouteRef'; +import { isSubRouteRef } from './SubRouteRef'; +import { isExternalRouteRef } from './ExternalRouteRef'; + +export class RouteResolver { + constructor( + private readonly routePaths: Map, + private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], + private readonly routeBindings: Map< + ExternalRouteRef, + RouteRef | SubRouteRef + >, + ) {} + + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + sourceLocation: ReturnType, + ): RouteFunc | undefined { + let resolvedRef: AnyRouteRef; + let subRoutePath = ''; + if (isRouteRef(anyRouteRef)) { + resolvedRef = anyRouteRef; + } else if (isSubRouteRef(anyRouteRef)) { + resolvedRef = anyRouteRef.parent; + subRoutePath = anyRouteRef.path; + } else if (isExternalRouteRef(anyRouteRef)) { + const resolvedRoute = this.routeBindings.get(anyRouteRef); + if (!resolvedRoute) { + return undefined; + } + if (isSubRouteRef(resolvedRoute)) { + subRoutePath = resolvedRoute.path; + resolvedRef = resolvedRoute.parent; + } else { + resolvedRef = resolvedRoute; + } + } else if (anyRouteRef[routeRefType]) { + throw new Error( + `Unknown or invalid route ref type, ${anyRouteRef[routeRefType]}`, + ); + } else { + throw new Error( + `Unknown object passed to useRouteRef, got ${anyRouteRef}`, + ); + } + + const match = matchRoutes(this.routeObjects, sourceLocation) ?? []; + + // If our route isn't bound to a path we fail the resolution and let the caller decide the failure mode + const resolvedPath = this.routePaths.get(resolvedRef); + if (!resolvedPath) { + return undefined; + } + // SubRouteRefs join the path from the parent route with its own path + const lastPath = + resolvedPath + + (resolvedPath.endsWith('/') ? subRoutePath.slice(1) : subRoutePath); + + const targetRefStack = Array(); + let matchIndex = -1; + + for ( + let currentRouteRef: AnyRouteRef | undefined = resolvedRef; + currentRouteRef; + currentRouteRef = this.routeParents.get(currentRouteRef) + ) { + matchIndex = match.findIndex(m => + (m.route as BackstageRouteObject).routeRefs.has(currentRouteRef!), + ); + if (matchIndex !== -1) { + break; + } + + targetRefStack.unshift(currentRouteRef); + } + + // If our target route is present in the initial match we need to construct the final path + // from the parent of the matched route segment. That's to allow the caller of the route + // function to supply their own params. + if (targetRefStack.length === 0) { + matchIndex -= 1; + } + + // This is the part of the route tree that the target and source locations have in common. + // We re-use the existing pathname directly along with all params. + const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; + + // This constructs the mid section of the path using paths resolved from all route refs + // we need to traverse to reach our target except for the very last one. None of these + // paths are allowed to require any parameters, as the called would have no way of knowing + // what parameters those are. + const prefixPath = targetRefStack + .slice(0, -1) + .map(ref => { + const path = this.routePaths.get(ref); + if (!path) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${resolvedRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }) + .join('/') + .replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s + + const routeFunc: RouteFunc = (...[params]) => { + return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`; + }; + return routeFunc; + } +} diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index e66b76b130..9a65286fd6 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -29,15 +29,11 @@ import { routeParentCollector, routeObjectCollector, } from './collectors'; -import { - useRouteRef, - RoutingProvider, - validateRoutes, - RouteFunc, -} from './hooks'; +import { validateRoutes } from './validation'; +import { useRouteRef, RoutingProvider } from './hooks'; import { createRouteRef, RouteRefConfig } from './RouteRef'; import { createExternalRouteRef } from './ExternalRouteRef'; -import { AnyRouteRef, RouteRef, ExternalRouteRef } from './types'; +import { AnyRouteRef, RouteFunc, RouteRef, ExternalRouteRef } from './types'; const mockConfig = (extra?: Partial>) => ({ path: '/unused', diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 2e305d8c38..bc43ff8685 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -15,7 +15,7 @@ */ import React, { createContext, ReactNode, useContext, useMemo } from 'react'; -import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { AnyRouteRef, BackstageRouteObject, @@ -23,135 +23,9 @@ import { ExternalRouteRef, AnyParams, SubRouteRef, - routeRefType, + RouteFunc, } from './types'; -import { isRouteRef } from './RouteRef'; -import { isSubRouteRef } from './SubRouteRef'; -import { isExternalRouteRef } from './ExternalRouteRef'; - -// The extra TS magic here is to require a single params argument if the RouteRef -// had at least one param defined, but require 0 arguments if there are no params defined. -// Without this we'd have to pass in empty object to all parameter-less RouteRefs -// just to make TypeScript happy, or we would have to make the argument optional in -// which case you might forget to pass it in when it is actually required. -export type RouteFunc = ( - ...[params]: Params extends undefined ? readonly [] : readonly [Params] -) => string; - -class RouteResolver { - constructor( - private readonly routePaths: Map, - private readonly routeParents: Map, - private readonly routeObjects: BackstageRouteObject[], - private readonly routeBindings: Map< - ExternalRouteRef, - RouteRef | SubRouteRef - >, - ) {} - - resolve( - anyRouteRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, - sourceLocation: ReturnType, - ): RouteFunc | undefined { - let resolvedRef: AnyRouteRef; - let subRoutePath = ''; - if (isRouteRef(anyRouteRef)) { - resolvedRef = anyRouteRef; - } else if (isSubRouteRef(anyRouteRef)) { - resolvedRef = anyRouteRef.parent; - subRoutePath = anyRouteRef.path; - } else if (isExternalRouteRef(anyRouteRef)) { - const resolvedRoute = this.routeBindings.get(anyRouteRef); - if (!resolvedRoute) { - return undefined; - } - if (isSubRouteRef(resolvedRoute)) { - subRoutePath = resolvedRoute.path; - resolvedRef = resolvedRoute.parent; - } else { - resolvedRef = resolvedRoute; - } - } else if (anyRouteRef[routeRefType]) { - throw new Error( - `Unknown or invalid route ref type, ${anyRouteRef[routeRefType]}`, - ); - } else { - throw new Error( - `Unknown object passed to useRouteRef, got ${anyRouteRef}`, - ); - } - - const match = matchRoutes(this.routeObjects, sourceLocation) ?? []; - - // If our route isn't bound to a path we fail the resolution and let the caller decide the failure mode - const resolvedPath = this.routePaths.get(resolvedRef); - if (!resolvedPath) { - return undefined; - } - // SubRouteRefs join the path from the parent route with its own path - const lastPath = - resolvedPath + - (resolvedPath.endsWith('/') ? subRoutePath.slice(1) : subRoutePath); - - const targetRefStack = Array(); - let matchIndex = -1; - - for ( - let currentRouteRef: AnyRouteRef | undefined = resolvedRef; - currentRouteRef; - currentRouteRef = this.routeParents.get(currentRouteRef) - ) { - matchIndex = match.findIndex(m => - (m.route as BackstageRouteObject).routeRefs.has(currentRouteRef!), - ); - if (matchIndex !== -1) { - break; - } - - targetRefStack.unshift(currentRouteRef); - } - - // If our target route is present in the initial match we need to construct the final path - // from the parent of the matched route segment. That's to allow the caller of the route - // function to supply their own params. - if (targetRefStack.length === 0) { - matchIndex -= 1; - } - - // This is the part of the route tree that the target and source locations have in common. - // We re-use the existing pathname directly along with all params. - const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; - - // This constructs the mid section of the path using paths resolved from all route refs - // we need to traverse to reach our target except for the very last one. None of these - // paths are allowed to require any parameters, as the called would have no way of knowing - // what parameters those are. - const prefixPath = targetRefStack - .slice(0, -1) - .map(ref => { - const path = this.routePaths.get(ref); - if (!path) { - throw new Error(`No path for ${ref}`); - } - if (path.includes(':')) { - throw new Error( - `Cannot route to ${resolvedRef} with parent ${ref} as it has parameters`, - ); - } - return path; - }) - .join('/') - .replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s - - const routeFunc: RouteFunc = (...[params]) => { - return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`; - }; - return routeFunc; - } -} +import { RouteResolver } from './RouteResolver'; const RoutingContext = createContext(undefined); @@ -213,42 +87,3 @@ export const RoutingProvider = ({ ); }; - -export function validateRoutes( - routePaths: Map, - routeParents: Map, -) { - const notLeafRoutes = new Set(routeParents.values()); - notLeafRoutes.delete(undefined); - - for (const route of routeParents.keys()) { - if (notLeafRoutes.has(route)) { - continue; - } - - let currentRouteRef: AnyRouteRef | undefined = route; - - let fullPath = ''; - while (currentRouteRef) { - const path = routePaths.get(currentRouteRef); - if (!path) { - throw new Error(`No path for ${currentRouteRef}`); - } - fullPath = `${path}${fullPath}`; - currentRouteRef = routeParents.get(currentRouteRef); - } - - const params = fullPath.match(/:(\w+)/g); - if (params) { - for (let j = 0; j < params.length; j++) { - for (let i = j + 1; i < params.length; i++) { - if (params[i] === params[j]) { - throw new Error( - `Parameter ${params[i]} is duplicated in path ${fullPath}`, - ); - } - } - } - } - } -} diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 80b1f0c4e7..65c12ff954 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -25,6 +25,15 @@ export type OptionalParams< Params extends { [param in string]: string } > = Params[keyof Params] extends never ? undefined : Params; +// The extra TS magic here is to require a single params argument if the RouteRef +// had at least one param defined, but require 0 arguments if there are no params defined. +// Without this we'd have to pass in empty object to all parameter-less RouteRefs +// just to make TypeScript happy, or we would have to make the argument optional in +// which case you might forget to pass it in when it is actually required. +export type RouteFunc = ( + ...[params]: Params extends undefined ? readonly [] : readonly [Params] +) => string; + export const routeRefType: unique symbol = getGlobalSingleton( 'route-ref-type', () => Symbol('route-ref-type'), diff --git a/packages/core-api/src/routing/validation.ts b/packages/core-api/src/routing/validation.ts new file mode 100644 index 0000000000..2d32471a14 --- /dev/null +++ b/packages/core-api/src/routing/validation.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnyRouteRef } from './types'; + +export function validateRoutes( + routePaths: Map, + routeParents: Map, +) { + const notLeafRoutes = new Set(routeParents.values()); + notLeafRoutes.delete(undefined); + + for (const route of routeParents.keys()) { + if (notLeafRoutes.has(route)) { + continue; + } + + let currentRouteRef: AnyRouteRef | undefined = route; + + let fullPath = ''; + while (currentRouteRef) { + const path = routePaths.get(currentRouteRef); + if (!path) { + throw new Error(`No path for ${currentRouteRef}`); + } + fullPath = `${path}${fullPath}`; + currentRouteRef = routeParents.get(currentRouteRef); + } + + const params = fullPath.match(/:(\w+)/g); + if (params) { + for (let j = 0; j < params.length; j++) { + for (let i = j + 1; i < params.length; i++) { + if (params[i] === params[j]) { + throw new Error( + `Parameter ${params[i]} is duplicated in path ${fullPath}`, + ); + } + } + } + } + } +}