app-api: refactor to depend on plugin-api and prune duplicates

Co-authored-by: Juan Lulkin <jmaiz@spotify.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-03-11 12:09:18 +01:00
parent 9de6eb0d5a
commit a21ece637a
46 changed files with 145 additions and 1721 deletions
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import { createApiRef } from '@backstage/plugin-api';
import { ApiAggregator } from './ApiAggregator';
import { createApiRef } from './ApiRef';
import { ApiRegistry } from './ApiRegistry';
describe('ApiAggregator', () => {
const apiARef = createApiRef<number>({ id: 'a', description: '' });
const apiBRef = createApiRef<number>({ id: 'b', description: '' });
const apiARef = createApiRef<number>({ id: 'a' });
const apiBRef = createApiRef<number>({ id: 'b' });
it('should forward implementations', () => {
const agg = new ApiAggregator(
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiRef, ApiHolder } from './types';
import { ApiRef, ApiHolder } from '@backstage/plugin-api';
/**
* An ApiHolder that queries multiple other holders from for
@@ -14,15 +14,15 @@
* limitations under the License.
*/
import { createApiRef } from '@backstage/plugin-api';
import { ApiFactoryRegistry } from './ApiFactoryRegistry';
import { createApiRef } from './ApiRef';
const aRef = createApiRef<number>({ id: 'a', description: '' });
const aRef = createApiRef<number>({ id: 'a' });
const aFactory1 = { api: aRef, deps: {}, factory: () => 1 };
const aFactory2 = { api: aRef, deps: {}, factory: () => 2 };
const bRef = createApiRef<string>({ id: 'b', description: '' });
const bRef = createApiRef<string>({ id: 'b' });
const bFactory = { api: bRef, deps: {}, factory: () => 'x' };
const cRef = createApiRef<string>({ id: 'c', description: '' });
const cRef = createApiRef<string>({ id: 'c' });
const cFactory = { api: cRef, deps: {}, factory: () => 'y' };
describe('ApiFactoryRegistry', () => {
@@ -69,8 +69,8 @@ describe('ApiFactoryRegistry', () => {
});
it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => {
const ref1 = createApiRef<number>({ id: 'a', description: 'ref1' });
const ref2 = createApiRef<number>({ id: 'a', description: 'ref2' });
const ref1 = createApiRef<number>({ id: 'a' });
const ref2 = createApiRef<number>({ id: 'a' });
const factory1 = { api: ref1, deps: {}, factory: () => 3 };
const factory2 = { api: ref2, deps: {}, factory: () => 3 };
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import { ApiFactoryHolder } from './types';
import {
ApiRef,
ApiFactoryHolder,
ApiFactory,
AnyApiRef,
AnyApiFactory,
} from './types';
} from '@backstage/plugin-api';
type ApiFactoryScope =
| 'default' // Default factories registered by core and plugins
@@ -15,18 +15,23 @@
*/
import React, { Context, useContext } from 'react';
import { ApiProvider, useApi, withApis } from './ApiProvider';
import { createApiRef } from './ApiRef';
import {
useApi,
createApiRef,
withApis,
ApiHolder,
ApiRef,
} from '@backstage/plugin-api';
import { ApiProvider } from './ApiProvider';
import { ApiRegistry } from './ApiRegistry';
import { render } from '@testing-library/react';
import { withLogCollector } from '@backstage/test-utils-core';
import { getGlobalSingleton } from '../../lib/globalObject';
import { ApiHolder, ApiRef } from './types';
import { VersionedValue } from '../../lib/versionedValues';
describe('ApiProvider', () => {
type Api = () => string;
const apiRef = createApiRef<Api>({ id: 'x', description: '' });
const apiRef = createApiRef<Api>({ id: 'x' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumer = () => {
@@ -55,8 +60,8 @@ describe('ApiProvider', () => {
});
it('should provide nested access to apis', () => {
const aRef = createApiRef<string>({ id: 'a', description: '' });
const bRef = createApiRef<string>({ id: 'b', description: '' });
const aRef = createApiRef<string>({ id: 'a' });
const bRef = createApiRef<string>({ id: 'b' });
const MyComponent = () => {
const a = useApi(aRef);
@@ -85,7 +90,7 @@ describe('ApiProvider', () => {
it('should ignore deps in prototype', () => {
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
const xRef = createApiRef<number>({ id: 'x', description: '' });
const xRef = createApiRef<number>({ id: 'x' });
const proto = { x: xRef };
const props = { getMessage: { enumerable: true, value: apiRef } };
@@ -193,7 +198,7 @@ describe('v1 consumer', () => {
}
type Api = () => string;
const apiRef = createApiRef<Api>({ id: 'x', description: '' });
const apiRef = createApiRef<Api>({ id: 'x' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumerV1 = () => {
@@ -19,26 +19,16 @@ import React, {
useContext,
ReactNode,
PropsWithChildren,
Context,
} from 'react';
import PropTypes from 'prop-types';
import { ApiRef, ApiHolder, TypesToApiRefs } from './types';
import { ApiHolder } from '@backstage/plugin-api';
import { ApiAggregator } from './ApiAggregator';
import {
getGlobalSingleton,
getOrCreateGlobalSingleton,
} from '../../lib/globalObject';
import { getOrCreateGlobalSingleton } from '../../lib/globalObject';
import {
VersionedValue,
createVersionedValueMap,
} from '../../lib/versionedValues';
const missingHolderMessage =
'No ApiProvider available in react context. ' +
'A common cause of this error is that multiple versions of @backstage/core-api are installed. ' +
`You can check if that is the case using 'yarn backstage-cli versions:check', and can in many cases ` +
`fix the issue either with the --fix flag or using 'yarn backstage-cli versions:bump'`;
type ApiProviderProps = {
apis: ApiHolder;
children: ReactNode;
@@ -68,62 +58,3 @@ ApiProvider.propTypes = {
apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired,
children: PropTypes.node,
};
export function useApiHolder(): ApiHolder {
const versionedHolder = useContext(
getGlobalSingleton<Context<ApiContextType>>('api-context'),
);
if (!versionedHolder) {
throw new Error(missingHolderMessage);
}
const apiHolder = versionedHolder.atVersion(1);
if (!apiHolder) {
throw new Error('ApiContext v1 not available');
}
return apiHolder;
}
export function useApi<T>(apiRef: ApiRef<T>): T {
const apiHolder = useApiHolder();
const api = apiHolder.get(apiRef);
if (!api) {
throw new Error(`No implementation available for ${apiRef}`);
}
return api;
}
export function withApis<T>(apis: TypesToApiRefs<T>) {
return function withApisWrapper<P extends T>(
WrappedComponent: React.ComponentType<P>,
) {
const Hoc = (props: PropsWithChildren<Omit<P, keyof T>>) => {
const apiHolder = useApiHolder();
const impls = {} as T;
for (const key in apis) {
if (apis.hasOwnProperty(key)) {
const ref = apis[key];
const api = apiHolder.get(ref);
if (!api) {
throw new Error(`No implementation available for ${ref}`);
}
impls[key] = api;
}
}
return <WrappedComponent {...(props as P)} {...impls} />;
};
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
Hoc.displayName = `withApis(${displayName})`;
return Hoc;
};
}
@@ -1,51 +0,0 @@
/*
* 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 { createApiRef } from './ApiRef';
describe('ApiRef', () => {
it('should be created', () => {
const ref = createApiRef({ id: 'abc', description: '123' });
expect(ref.id).toBe('abc');
expect(ref.description).toBe('123');
expect(String(ref)).toBe('apiRef{abc}');
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
});
it('should reject invalid ids', () => {
for (const id of ['a', 'abc', 'ab-c', 'a.b.c', 'a-b.c', 'abc.a-b-c.abc3']) {
expect(createApiRef({ id, description: '123' }).id).toBe(id);
}
for (const id of [
'123',
'ab-3',
'ab_c',
'.',
'2ac',
'ab.3a',
'.abc',
'abc.',
'ab..s',
'',
'_',
]) {
expect(() => createApiRef({ id, description: '123' }).id).toThrow(
`API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`,
);
}
});
});
@@ -1,57 +0,0 @@
/*
* 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 type { ApiRef } from './types';
export type ApiRefConfig = {
id: string;
description?: string;
};
class ApiRefImpl<T> implements ApiRef<T> {
constructor(private readonly config: ApiRefConfig) {
const valid = config.id
.split('.')
.flatMap(part => part.split('-'))
.every(part => part.match(/^[a-z][a-z0-9]*$/));
if (!valid) {
throw new Error(
`API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`,
);
}
}
get id(): string {
return this.config.id;
}
get description() {
return this.config.description;
}
// Utility for getting type of an api, using `typeof apiRef.T`
get T(): T {
throw new Error(`tried to read ApiRef.T of ${this}`);
}
toString() {
return `apiRef{${this.config.id}}`;
}
}
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> {
return new ApiRefImpl<T>(config);
}
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import { createApiRef } from '@backstage/plugin-api';
import { ApiRegistry } from './ApiRegistry';
import { createApiRef } from './ApiRef';
describe('ApiRegistry', () => {
const x1Ref = createApiRef<number>({ id: 'x1', description: '' });
const x1DuplicateRef = createApiRef<number>({ id: 'x1', description: '' });
const x2Ref = createApiRef<string>({ id: 'x2', description: '' });
const x1Ref = createApiRef<number>({ id: 'x1' });
const x1DuplicateRef = createApiRef<number>({ id: 'x1' });
const x2Ref = createApiRef<string>({ id: 'x2' });
it('should be created', () => {
const registry = ApiRegistry.from([]);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiRef, ApiHolder } from './types';
import { ApiRef, ApiHolder } from '@backstage/plugin-api';
type ApiImpl<T = unknown> = readonly [ApiRef<T>, T];
@@ -14,19 +14,16 @@
* limitations under the License.
*/
import { createApiRef } from '@backstage/plugin-api';
import { ApiResolver } from './ApiResolver';
import { createApiRef } from './ApiRef';
import { ApiFactoryRegistry } from './ApiFactoryRegistry';
const aRef = createApiRef<number>({ id: 'a', description: '' });
const otherARef = createApiRef<number>({ id: 'a', description: 'other' });
const bRef = createApiRef<string>({ id: 'b', description: '' });
const otherBRef = createApiRef<string>({ id: 'b', description: 'other' });
const cRef = createApiRef<{ x: string }>({ id: 'c', description: '' });
const otherCRef = createApiRef<{ x: string }>({
id: 'c',
description: 'other',
});
const aRef = createApiRef<number>({ id: 'a' });
const otherARef = createApiRef<number>({ id: 'a' });
const bRef = createApiRef<string>({ id: 'b' });
const otherBRef = createApiRef<string>({ id: 'b' });
const cRef = createApiRef<{ x: string }>({ id: 'c' });
const otherCRef = createApiRef<{ x: string }>({ id: 'c' });
function createRegistry() {
const registry = new ApiFactoryRegistry();
@@ -17,10 +17,10 @@
import {
ApiRef,
ApiHolder,
ApiFactoryHolder,
AnyApiRef,
TypesToApiRefs,
} from './types';
} from '@backstage/plugin-api';
import { ApiFactoryHolder } from './types';
export class ApiResolver implements ApiHolder {
/**
@@ -1,49 +0,0 @@
/*
* 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 { ApiRef, ApiFactory, TypesToApiRefs } from './types';
/**
* Used to infer types for a standalone ApiFactory that isn't immediately passed
* to another function.
* This function doesn't actually do anything, it's only used to infer types.
*/
export function createApiFactory<
Api,
Impl extends Api,
Deps extends { [name in string]: unknown }
>(factory: ApiFactory<Api, Impl, Deps>): ApiFactory<Api, Impl, Deps>;
export function createApiFactory<Api, Impl extends Api>(
api: ApiRef<Api>,
instance: Impl,
): ApiFactory<Api, Impl, {}>;
export function createApiFactory<
Api,
Impl extends Api,
Deps extends { [name in string]: unknown }
>(
factory: ApiFactory<Api, Impl, Deps> | ApiRef<Api>,
instance?: Impl,
): ApiFactory<Api, Impl, Deps> {
if ('id' in factory) {
return {
api: factory,
deps: {} as TypesToApiRefs<Deps>,
factory: () => instance!,
};
}
return factory;
}
+1 -3
View File
@@ -14,10 +14,8 @@
* limitations under the License.
*/
export { ApiProvider, useApi, useApiHolder } from './ApiProvider';
export { ApiProvider } from './ApiProvider';
export { ApiRegistry } from './ApiRegistry';
export { ApiResolver } from './ApiResolver';
export { ApiFactoryRegistry } from './ApiFactoryRegistry';
export { createApiRef } from './ApiRef';
export * from './types';
export * from './helpers';
+1 -35
View File
@@ -14,41 +14,7 @@
* limitations under the License.
*/
export type ApiRef<T> = {
id: string;
description?: string;
T: T;
};
export type AnyApiRef = ApiRef<unknown>;
export type ApiRefType<T> = T extends ApiRef<infer U> ? U : never;
export type TypesToApiRefs<T> = { [key in keyof T]: ApiRef<T[key]> };
export type ApiRefsToTypes<T extends { [key in string]: ApiRef<unknown> }> = {
[key in keyof T]: ApiRefType<T[key]>;
};
export type ApiHolder = {
get<T>(api: ApiRef<T>): T | undefined;
};
export type ApiFactory<
Api,
Impl extends Api,
Deps extends { [name in string]: unknown }
> = {
api: ApiRef<Api>;
deps: TypesToApiRefs<Deps>;
factory(deps: Deps): Impl;
};
export type AnyApiFactory = ApiFactory<
unknown,
unknown,
{ [key in string]: unknown }
>;
import { ApiFactory, ApiRef } from '@backstage/plugin-api';
export type ApiFactoryHolder = {
get<T>(
+3 -3
View File
@@ -21,13 +21,13 @@ import React, { PropsWithChildren } from 'react';
import { BrowserRouter, Routes } from 'react-router-dom';
import { createRoutableExtension } from '../extensions';
import { defaultAppIcons } from './icons';
import { createPlugin } from '../plugin';
import { useRouteRef } from '../routing/hooks';
import {
createPlugin,
useRouteRef,
createExternalRouteRef,
createRouteRef,
createSubRouteRef,
} from '../routing';
} from '@backstage/plugin-api';
import { generateBoundRoutes, PrivateAppImpl } from './App';
describe('generateBoundRoutes', () => {
+12 -86
View File
@@ -21,19 +21,19 @@ import React, {
useMemo,
useState,
} from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Route, Routes } from 'react-router-dom';
import { useAsync } from 'react-use';
import {
AnyApiFactory,
ApiHolder,
ApiProvider,
ApiRegistry,
AppThemeSelector,
ConfigReader,
LocalStorageFeatureFlags,
useApi,
} from '../apis';
import {
useApi,
AnyApiFactory,
ApiHolder,
IconComponent,
AppTheme,
appThemeApiRef,
@@ -42,6 +42,9 @@ import {
ConfigApi,
featureFlagsApiRef,
identityApiRef,
BackstagePlugin,
RouteRef,
ExternalRouteRef,
} from '@backstage/plugin-api';
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
import {
@@ -49,15 +52,12 @@ import {
routeElementDiscoverer,
traverseElementTree,
} from '../extensions/traversal';
import { BackstagePlugin } from '../plugin';
import { AnyRoutes } from '../plugin/types';
import { RouteRef, ExternalRouteRef } from '../routing';
import {
routeObjectCollector,
routeParentCollector,
routePathCollector,
} from '../routing/collectors';
import { RoutingProvider } from '../routing/hooks';
import { RoutingProvider } from '../routing/RoutingProvider';
import { validateRoutes } from '../routing/validation';
import { AppContextProvider } from './AppContext';
import { AppIdentity } from './AppIdentity';
@@ -79,7 +79,10 @@ export function generateBoundRoutes(
const result = new Map<ExternalRouteRef, RouteRef>();
if (bindRoutes) {
const bind: AppRouteBinder = (externalRoutes, targetRoutes: AnyRoutes) => {
const bind: AppRouteBinder = (
externalRoutes,
targetRoutes: { [name: string]: RouteRef<any> },
) => {
for (const [key, value] of Object.entries(targetRoutes)) {
const externalRoute = externalRoutes[key];
if (!externalRoute) {
@@ -150,12 +153,6 @@ function useConfigLoader(
class AppContextImpl implements AppContext {
constructor(private readonly app: PrivateAppImpl) {}
getPlugins(): BackstagePlugin<any, any>[] {
// eslint-disable-next-line no-console
console.warn('appContext.getPlugins() is deprecated and will be removed');
return this.app.getPlugins();
}
getSystemIcon(key: string): IconComponent | undefined {
return this.app.getSystemIcon(key);
}
@@ -163,24 +160,6 @@ class AppContextImpl implements AppContext {
getComponents(): AppComponents {
return this.app.getComponents();
}
getProvider(): React.ComponentType<{}> {
// eslint-disable-next-line no-console
console.warn('appContext.getProvider() is deprecated and will be removed');
return this.app.getProvider();
}
getRouter(): React.ComponentType<{}> {
// eslint-disable-next-line no-console
console.warn('appContext.getRouter() is deprecated and will be removed');
return this.app.getRouter();
}
getRoutes(): JSX.Element[] {
// eslint-disable-next-line no-console
console.warn('appContext.getRoutes() is deprecated and will be removed');
return this.app.getRoutes();
}
}
export class PrivateAppImpl implements BackstageApp {
@@ -221,59 +200,6 @@ export class PrivateAppImpl implements BackstageApp {
return this.components;
}
getRoutes(): JSX.Element[] {
const routes = new Array<JSX.Element>();
const { NotFoundErrorPage } = this.components;
for (const plugin of this.plugins.values()) {
for (const output of plugin.output()) {
switch (output.type) {
case 'legacy-route': {
const { path, component: Component } = output;
routes.push(
<Route key={path} path={path} element={<Component />} />,
);
break;
}
case 'route': {
const { target, component: Component } = output;
routes.push(
<Route
key={`${plugin.getId()}-${target.path}`}
path={target.path}
element={<Component />}
/>,
);
break;
}
case 'legacy-redirect-route': {
const { path, target } = output;
routes.push(<Navigate key={path} to={target} />);
break;
}
case 'redirect-route': {
const { from, to } = output;
routes.push(<Navigate key={from.path} to={to.path} />);
break;
}
default:
break;
}
}
}
routes.push(
<Route
key="not-found-error-page"
path="/*"
element={<NotFoundErrorPage />}
/>,
);
return routes;
}
getProvider(): ComponentType<{}> {
const appContext = new AppContextImpl(this);
const apiHolder = this.getApiHolder();
@@ -38,10 +38,6 @@ describe('v1 consumer', () => {
const mockContext: AppContextV1 = {
getComponents: jest.fn(),
getSystemIcon: jest.fn(),
getPlugins: jest.fn(),
getProvider: jest.fn(),
getRouter: jest.fn(),
getRoutes: jest.fn(),
};
const renderedHook = renderHook(() => useMockAppV1(), {
@@ -59,21 +55,5 @@ describe('v1 consumer', () => {
result.getSystemIcon('icon');
expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(1);
expect(mockContext.getSystemIcon).toHaveBeenCalledWith('icon');
expect(mockContext.getPlugins).toHaveBeenCalledTimes(0);
result.getPlugins();
expect(mockContext.getPlugins).toHaveBeenCalledTimes(1);
expect(mockContext.getProvider).toHaveBeenCalledTimes(0);
result.getProvider();
expect(mockContext.getProvider).toHaveBeenCalledTimes(1);
expect(mockContext.getRouter).toHaveBeenCalledTimes(0);
result.getRouter();
expect(mockContext.getRouter).toHaveBeenCalledTimes(1);
expect(mockContext.getRoutes).toHaveBeenCalledTimes(0);
result.getRoutes();
expect(mockContext.getRoutes).toHaveBeenCalledTimes(1);
});
});
+2 -24
View File
@@ -14,20 +14,12 @@
* limitations under the License.
*/
import React, {
createContext,
PropsWithChildren,
useContext,
Context,
} from 'react';
import React, { createContext, PropsWithChildren } from 'react';
import {
VersionedValue,
createVersionedValueMap,
} from '../lib/versionedValues';
import {
getGlobalSingleton,
getOrCreateGlobalSingleton,
} from '../lib/globalObject';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
import { AppContext as AppContextV1 } from './types';
type AppContextType = VersionedValue<{ 1: AppContextV1 }> | undefined;
@@ -47,17 +39,3 @@ export const AppContextProvider = ({
return <AppContext.Provider value={versionedValue} children={children} />;
};
export const useApp = (): AppContextV1 => {
const versionedContext = useContext(
getGlobalSingleton<Context<AppContextType>>('app-context'),
);
if (!versionedContext) {
throw new Error('No app context available');
}
const appContext = versionedContext.atVersion(1);
if (!appContext) {
throw new Error('AppContext v1 not available');
}
return appContext;
};
+1 -1
View File
@@ -27,13 +27,13 @@ import {
MicrosoftAuth,
OAuthRequestManager,
WebStorage,
createApiFactory,
UrlPatternDiscovery,
SamlAuth,
OneLoginAuth,
} from '../apis';
import {
createApiFactory,
alertApiRef,
errorApiRef,
discoveryApiRef,
+1 -1
View File
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { useApp } from './AppContext';
export { createApp } from './createApp';
export * from './types';
+16 -34
View File
@@ -15,12 +15,17 @@
*/
import { ComponentType } from 'react';
import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types';
import { ExternalRouteRef, RouteRef } from '../routing';
import { AnyApiFactory } from '../apis';
import { AppTheme, ProfileInfo, IconComponent } from '@backstage/plugin-api';
import {
AnyApiFactory,
AppTheme,
ProfileInfo,
IconComponent,
BackstagePlugin,
RouteRef,
SubRouteRef,
ExternalRouteRef,
} from '@backstage/plugin-api';
import { AppConfig } from '@backstage/config';
import { SubRouteRef } from '../routing/types';
import { AppIcons } from './icons';
export type BootErrorPageProps = {
@@ -98,7 +103,9 @@ type PartialKeys<
/**
* Creates a map of target routes with matching parameters based on a map of external routes.
*/
type TargetRouteMap<ExternalRoutes extends AnyExternalRoutes> = {
type TargetRouteMap<
ExternalRoutes extends { [name: string]: ExternalRouteRef }
> = {
[name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef<
infer Params,
any
@@ -107,7 +114,9 @@ type TargetRouteMap<ExternalRoutes extends AnyExternalRoutes> = {
: never;
};
export type AppRouteBinder = <ExternalRoutes extends AnyExternalRoutes>(
export type AppRouteBinder = <
ExternalRoutes extends { [name: string]: ExternalRouteRef }
>(
externalRoutes: ExternalRoutes,
targetRoutes: PartialKeys<
TargetRouteMap<ExternalRoutes>,
@@ -215,21 +224,9 @@ export type BackstageApp = {
* and any other components that should only be available while signed in.
*/
getRouter(): ComponentType<{}>;
/**
* Routes component that contains all routes for plugin pages in the app.
*
* @deprecated Registering routes in plugins is deprecated and this method will be removed.
*/
getRoutes(): JSX.Element[];
};
export type AppContext = {
/**
* @deprecated Will be removed
*/
getPlugins(): BackstagePlugin<any, any>[];
/**
* Get a common or custom icon for this app.
*/
@@ -239,19 +236,4 @@ export type AppContext = {
* Get the components registered for various purposes in the app.
*/
getComponents(): AppComponents;
/**
* @deprecated Will be removed
*/
getProvider(): ComponentType<{}>;
/**
* @deprecated Will be removed
*/
getRouter(): ComponentType<{}>;
/**
* @deprecated Will be removed
*/
getRoutes(): JSX.Element[];
};
@@ -15,8 +15,7 @@
*/
import React from 'react';
import { createPlugin } from '../plugin';
import { createRouteRef } from '../routing';
import { createPlugin, createRouteRef } from '@backstage/plugin-api';
import { getComponentData } from './componentData';
import {
createComponentExtension,
@@ -50,7 +49,7 @@ describe('extensions', () => {
it('should create react extensions of different types', () => {
const Component = () => <div />;
const routeRef = createRouteRef({ path: '/foo', title: 'Foo' });
const routeRef = createRouteRef({ id: 'foo' });
const extension1 = createComponentExtension({
component: {
@@ -15,9 +15,13 @@
*/
import React, { lazy, Suspense } from 'react';
import { RouteRef, useRouteRef } from '../routing';
import { attachComponentData } from './componentData';
import { Extension, BackstagePlugin } from '../plugin/types';
import {
Extension,
BackstagePlugin,
RouteRef,
useRouteRef,
} from '@backstage/plugin-api';
type ComponentLoader<T> =
| {
+1 -1
View File
@@ -17,7 +17,7 @@
import * as index from '.';
describe('index', () => {
it('exports the plugin api', () => {
it('exports the app api', () => {
expect(index).toEqual({
// Public API
createApp: expect.any(Function),
-2
View File
@@ -16,7 +16,5 @@
export * from './apis';
export * from './app';
export * from './extensions';
export * from './plugin';
export * from './routing';
export * from './types';
-99
View File
@@ -1,99 +0,0 @@
/*
* 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 {
PluginConfig,
PluginOutput,
BackstagePlugin,
Extension,
AnyRoutes,
AnyExternalRoutes,
} from './types';
import { AnyApiFactory } from '../apis';
export class PluginImpl<
Routes extends AnyRoutes,
ExternalRoutes extends AnyExternalRoutes
> implements BackstagePlugin<Routes, ExternalRoutes> {
private storedOutput?: PluginOutput[];
constructor(private readonly config: PluginConfig<Routes, ExternalRoutes>) {}
getId(): string {
return this.config.id;
}
getApis(): Iterable<AnyApiFactory> {
return this.config.apis ?? [];
}
get routes(): Routes {
return this.config.routes ?? ({} as Routes);
}
get externalRoutes(): ExternalRoutes {
return this.config.externalRoutes ?? ({} as ExternalRoutes);
}
output(): PluginOutput[] {
if (this.storedOutput) {
return this.storedOutput;
}
if (!this.config.register) {
return [];
}
const outputs = new Array<PluginOutput>();
this.config.register({
router: {
addRoute(target, component, options) {
outputs.push({
type: 'route',
target,
component,
options,
});
},
},
featureFlags: {
register(name) {
outputs.push({ type: 'feature-flag', name });
},
},
});
this.storedOutput = outputs;
return this.storedOutput;
}
provide<T>(extension: Extension<T>): T {
return extension.expose(this);
}
toString() {
return `plugin{${this.config.id}}`;
}
}
export function createPlugin<
Routes extends AnyRoutes = {},
ExternalRoutes extends AnyExternalRoutes = {}
>(
config: PluginConfig<Routes, ExternalRoutes>,
): BackstagePlugin<Routes, ExternalRoutes> {
return new PluginImpl(config);
}
@@ -1,106 +0,0 @@
/*
* 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 React, { PropsWithChildren } from 'react';
import { createRouteRef } from '../routing';
import { createPlugin } from './Plugin';
import {
createRoutableExtension,
createComponentExtension,
} from '../extensions';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import {
traverseElementTree,
childDiscoverer,
routeElementDiscoverer,
} from '../extensions/traversal';
import { pluginCollector } from './collectors';
const mockConfig = () => ({ path: '/foo', title: 'Foo' });
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
<>{children}</>
);
const pluginA = createPlugin({ id: 'my-plugin-a' });
const pluginB = createPlugin({ id: 'my-plugin-b' });
const pluginC = createPlugin({ id: 'my-plugin-c' });
const ref1 = createRouteRef(mockConfig());
const ref2 = createRouteRef(mockConfig());
const Extension1 = pluginA.provide(
createRoutableExtension({
component: () => Promise.resolve(MockComponent),
mountPoint: ref1,
}),
);
const Extension2 = pluginB.provide(
createRoutableExtension({
component: () => Promise.resolve(MockComponent),
mountPoint: ref2,
}),
);
const Extension3 = pluginA.provide(
createComponentExtension({ component: { sync: MockComponent } }),
);
const Extension4 = pluginB.provide(
createComponentExtension({ component: { sync: MockComponent } }),
);
const Extension5 = pluginC.provide(
createComponentExtension({ component: { sync: MockComponent } }),
);
describe('collection', () => {
it('should collect the plugins', () => {
const root = (
<MemoryRouter>
<Routes>
<Extension1 path="/foo">
<div>
<Extension2 path="/bar/:id">
<div>
<div />
{[<Extension4 key={0} />]}
Some text here shouldn't be a problem
<div />
{null}
<div />
<Extension3 />
</div>
</Extension2>
{false}
{true}
{0}
</div>
</Extension1>
<div>
<Route path="/divsoup" element={<Extension5 />} />
</div>
</Routes>
</MemoryRouter>
);
const { plugins } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
plugins: pluginCollector,
},
});
expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC]));
});
});
-47
View File
@@ -1,47 +0,0 @@
/*
* 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.
*/
/*
* 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 { BackstagePlugin } from './types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
export const pluginCollector = createCollector(
() => new Set<BackstagePlugin<any, any>>(),
(acc, node) => {
const plugin = getComponentData<BackstagePlugin<any, any>>(
node,
'core.plugin',
);
if (plugin) {
acc.add(plugin);
}
},
);
-33
View File
@@ -1,33 +0,0 @@
/*
* 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.
*/
export { createPlugin } from './Plugin';
export type {
BackstagePlugin,
Extension,
FeatureFlagOutput,
FeatureFlagsHooks,
LegacyRedirectRouteOutput,
LegacyRouteOutput,
PluginConfig,
PluginHooks,
PluginOutput,
RedirectRouteOutput,
RouteOptions,
RouteOutput,
RoutePath,
RouterHooks,
} from './types';
-121
View File
@@ -1,121 +0,0 @@
/*
* 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 { ComponentType } from 'react';
import { RouteRef, ExternalRouteRef } from '../routing';
import { AnyApiFactory } from '../apis/system';
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
exact?: boolean;
};
export type RoutePath = string;
// Replace with using RouteRefs
export type LegacyRouteOutput = {
type: 'legacy-route';
path: RoutePath;
component: ComponentType<{}>;
options?: RouteOptions;
};
export type RouteOutput = {
type: 'route';
target: RouteRef;
component: ComponentType<{}>;
options?: RouteOptions;
};
export type RedirectRouteOutput = {
type: 'redirect-route';
from: RouteRef;
to: RouteRef;
options?: RouteOptions;
};
export type LegacyRedirectRouteOutput = {
type: 'legacy-redirect-route';
path: RoutePath;
target: RoutePath;
options?: RouteOptions;
};
export type FeatureFlagOutput = {
type: 'feature-flag';
name: string;
};
export type PluginOutput =
| LegacyRouteOutput
| RouteOutput
| LegacyRedirectRouteOutput
| RedirectRouteOutput
| FeatureFlagOutput;
export type Extension<T> = {
expose(plugin: BackstagePlugin<any, any>): T;
};
export type AnyRoutes = { [name: string]: RouteRef<any> };
export type AnyExternalRoutes = { [name: string]: ExternalRouteRef };
export type BackstagePlugin<
Routes extends AnyRoutes = {},
ExternalRoutes extends AnyExternalRoutes = {}
> = {
getId(): string;
output(): PluginOutput[];
getApis(): Iterable<AnyApiFactory>;
provide<T>(extension: Extension<T>): T;
routes: Routes;
externalRoutes: ExternalRoutes;
};
export type PluginConfig<
Routes extends AnyRoutes,
ExternalRoutes extends AnyExternalRoutes
> = {
id: string;
apis?: Iterable<AnyApiFactory>;
register?(hooks: PluginHooks): void;
routes?: Routes;
externalRoutes?: ExternalRoutes;
};
export type PluginHooks = {
/**
* @deprecated All router hooks have been deprecated
*/
router: RouterHooks;
featureFlags: FeatureFlagsHooks;
};
export type RouterHooks = {
/**
* @deprecated Use a routable extension instead, see https://backstage.io/docs/plugins/composability#porting-existing-plugins
*/
addRoute(
target: RouteRef,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
};
export type FeatureFlagsHooks = {
register(name: string): void;
};
@@ -1,119 +0,0 @@
/*
* 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 { AnyParams, ExternalRouteRef } from './types';
import { createExternalRouteRef, isExternalRouteRef } from './ExternalRouteRef';
import { isSubRouteRef } from './SubRouteRef';
import { isRouteRef } from './RouteRef';
describe('ExternalRouteRef', () => {
it('should be created', () => {
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef({
id: 'my-route-ref',
});
expect(routeRef.params).toEqual([]);
expect(routeRef.optional).toBe(false);
expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}');
expect(isRouteRef(routeRef)).toBe(false);
expect(isSubRouteRef(routeRef)).toBe(false);
expect(isExternalRouteRef(routeRef)).toBe(true);
expect(isRouteRef({} as ExternalRouteRef)).toBe(false);
});
it('should be created as optional', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: [],
optional: true,
});
expect(routeRef.params).toEqual([]);
expect(routeRef.optional).toEqual(true);
});
it('should be created with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
});
expect(routeRef.params).toEqual(['x', 'y']);
expect(routeRef.optional).toEqual(false);
});
it('should be created as optional with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
optional: true,
});
expect(routeRef.params).toEqual(['x', 'y']);
expect(routeRef.optional).toEqual(true);
});
it('should properly infer and validate parameter types and assignments', () => {
function validateType<T extends AnyParams, O extends boolean>(
_ref: ExternalRouteRef<T, O>,
) {}
const _1 = createExternalRouteRef({ id: '1', params: ['notX'] });
// @ts-expect-error
validateType<{ x: string }, any>(_1);
validateType<{ notX: string }, any>(_1);
const _2 = createExternalRouteRef({
id: '2',
params: ['x'],
optional: true,
});
// @ts-expect-error
validateType<undefined, any>(_2);
validateType<{ x: string }, true>(_2);
const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] });
// @ts-expect-error
validateType<{ x: string }, any>(_3);
// TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead
validateType<{ x: string; y: string; z: string }, any>(_3);
validateType<{ x: string; y: string }, false>(_3);
const _4 = createExternalRouteRef({ id: '4', params: [] });
// @ts-expect-error
validateType<{ x: string }, any>(_4);
validateType<undefined, false>(_4);
const _5 = createExternalRouteRef({ id: '5' });
// @ts-expect-error
validateType<{ x: string }, any>(_5);
validateType<undefined, false>(_5);
const _6 = createExternalRouteRef({ id: '6', optional: true });
// @ts-expect-error
validateType<undefined, false>(_6);
validateType<undefined, true>(_6);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String));
});
});
@@ -1,84 +0,0 @@
/*
* 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 {
RouteRef,
SubRouteRef,
ExternalRouteRef,
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
export class ExternalRouteRefImpl<
Params extends AnyParams,
Optional extends boolean
> implements ExternalRouteRef<Params, Optional> {
readonly [routeRefType] = 'external';
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
readonly optional: Optional,
) {}
toString() {
return `routeRef{type=external,id=${this.id}}`;
}
}
export function createExternalRouteRef<
Params extends { [param in ParamKey]: string },
Optional extends boolean = false,
ParamKey extends string = never
>(options: {
/**
* An identifier for this route, used to identify it in error messages
*/
id: string;
/**
* The parameters that will be provided to the external route reference.
*/
params?: ParamKey[];
/**
* Whether or not this route is optional, defaults to false.
*
* Optional external routes are not required to be bound in the app, and
* if they aren't, `useRouteRef` will return `undefined`.
*/
optional?: Optional;
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
return new ExternalRouteRefImpl(
options.id,
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
Boolean(options.optional) as Optional,
);
}
export function isExternalRouteRef<
Params extends AnyParams,
Optional extends boolean
>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, Optional>,
): routeRef is ExternalRouteRef<Params, Optional> {
return routeRef[routeRefType] === 'external';
}
+1 -1
View File
@@ -16,7 +16,7 @@
import React, { ReactNode, Children, isValidElement, Fragment } from 'react';
import { useRoutes } from 'react-router-dom';
import { useApp } from '../app';
import { useApp } from '@backstage/plugin-api';
type RouteObject = {
path: string;
@@ -1,94 +0,0 @@
/*
* 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 { AnyParams, RouteRef } from './types';
import { createRouteRef, isRouteRef } from './RouteRef';
import { isSubRouteRef } from './SubRouteRef';
import { isExternalRouteRef } from './ExternalRouteRef';
import MyIcon from '@material-ui/icons/AcUnit';
describe('RouteRef', () => {
it('should be created', () => {
const routeRef: RouteRef<undefined> = createRouteRef({
id: 'my-route-ref',
});
expect(routeRef.params).toEqual([]);
expect(String(routeRef)).toBe('routeRef{type=absolute,id=my-route-ref}');
expect(isRouteRef(routeRef)).toBe(true);
expect(isSubRouteRef(routeRef)).toBe(false);
expect(isExternalRouteRef(routeRef)).toBe(false);
expect(isRouteRef({} as RouteRef)).toBe(false);
});
it('should be created with params', () => {
const routeRef: RouteRef<{
x: string;
y: string;
}> = createRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
});
expect(routeRef.params).toEqual(['x', 'y']);
});
it('should properly infer and validate parameter types and assignments', () => {
function validateType<T extends AnyParams>(_ref: RouteRef<T>) {}
const _1 = createRouteRef({ id: '1', params: ['x'] });
// @ts-expect-error
validateType<{ y: string }>(_1);
// @ts-expect-error
validateType<undefined>(_1);
validateType<{ x: string }>(_1);
const _2 = createRouteRef({ id: '2', params: ['x', 'y'] });
// @ts-expect-error
validateType<{ x: string }>(_2);
// @ts-expect-error
validateType<undefined>(_2);
// @ts-expect-error
validateType<{ x: string; z: string }>(_2);
// TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead
validateType<{ x: string; y: string; z: string }>(_2);
validateType<{ x: string; y: string }>(_2);
const _3 = createRouteRef({ id: '3', params: [] });
// @ts-expect-error
validateType<{ x: string }>(_3);
validateType<undefined>(_3);
const _4 = createRouteRef({ id: '4' });
// @ts-expect-error
validateType<{ x: string }>(_4);
validateType<undefined>(_4);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
});
it('should support deprecated access', () => {
const routeRef = createRouteRef({
title: 'My Ref',
path: '/my-path',
icon: MyIcon,
});
expect(routeRef.title).toBe('My Ref');
expect(routeRef.path).toBe('/my-path');
expect(routeRef.icon).toBe(MyIcon);
expect(String(routeRef)).toBe('routeRef{type=absolute,id=My Ref}');
});
});
-106
View File
@@ -1,106 +0,0 @@
/*
* 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 {
RouteRef,
SubRouteRef,
ExternalRouteRef,
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
import { IconComponent } from '@backstage/plugin-api';
// TODO(Rugvip): Remove this in the next breaking release, it's exported but unused
export type RouteRefConfig<Params extends AnyParams> = {
params?: ParamKeys<Params>;
path?: string;
icon?: IconComponent;
title: string;
};
export class RouteRefImpl<Params extends AnyParams>
implements RouteRef<Params> {
readonly [routeRefType] = 'absolute';
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
private readonly config: {
path?: string;
icon?: IconComponent;
title?: string;
},
) {}
get icon() {
return this.config.icon;
}
// TODO(Rugvip): Remove this, routes are looked up via the registry instead
get path() {
return this.config.path ?? '';
}
get title() {
return this.config.title ?? this.id;
}
toString() {
return `routeRef{type=absolute,id=${this.id}}`;
}
}
export function createRouteRef<
// Params is the type that we care about and the one to be embedded in the route ref.
// For example, given the params ['name', 'kind'], Params will be {name: string, kind: string}
Params extends { [param in ParamKey]: string },
// ParamKey is here to make sure the Params type properly has its keys narrowed down
// to only the elements of params. Defaulting to never makes sure we end up with
// Param = {} if the params array is empty.
ParamKey extends string = never
>(config: {
/** The id of the route ref, used to identify it when printed */
id?: string;
/** A list of parameter names that the path that this route ref is bound to must contain */
params?: ParamKey[];
/** @deprecated Route refs no longer decide their own path */
path?: string;
/** @deprecated Route refs no longer decide their own icon */
icon?: IconComponent;
/** @deprecated Route refs no longer decide their own title */
title?: string;
}): RouteRef<OptionalParams<Params>> {
const id = config.id || config.title;
if (!id) {
throw new Error('RouteRef must be provided a non-empty id');
}
return new RouteRefImpl(
id,
(config.params ?? []) as ParamKeys<OptionalParams<Params>>,
config,
);
}
export function isRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
): routeRef is RouteRef<Params> {
return routeRef[routeRefType] === 'absolute';
}
@@ -14,11 +14,15 @@
* limitations under the License.
*/
import { createRouteRef } from './RouteRef';
import { createSubRouteRef } from './SubRouteRef';
import { createExternalRouteRef } from './ExternalRouteRef';
import {
createRouteRef,
createSubRouteRef,
createExternalRouteRef,
ExternalRouteRef,
RouteRef,
SubRouteRef,
} from '@backstage/plugin-api';
import { RouteResolver } from './RouteResolver';
import { ExternalRouteRef, RouteRef, SubRouteRef } from './types';
const element = () => null;
const rest = { element, caseSensitive: false };
@@ -18,16 +18,18 @@ import { generatePath, matchRoutes } from 'react-router-dom';
import {
AnyRouteRef,
BackstageRouteObject,
AnyParams,
RouteFunc,
routeRefType,
} from './types';
import {
RouteRef,
ExternalRouteRef,
AnyParams,
SubRouteRef,
routeRefType,
RouteFunc,
} from './types';
import { isRouteRef } from './RouteRef';
import { isSubRouteRef } from './SubRouteRef';
import { isExternalRouteRef } from './ExternalRouteRef';
isRouteRef,
isSubRouteRef,
isExternalRouteRef,
} from '@backstage/plugin-api';
// Joins a list of paths together, avoiding trailing and duplicate slashes
function joinPaths(...paths: string[]): string {
@@ -31,38 +31,35 @@ import {
routeElementDiscoverer,
traverseElementTree,
} from '../extensions/traversal';
import { createPlugin } from '../plugin';
import {
createPlugin,
useRouteRef,
createRouteRef,
createExternalRouteRef,
RouteRef,
ExternalRouteRef,
} from '@backstage/plugin-api';
import { RoutingProvider } from './RoutingProvider';
import {
routePathCollector,
routeParentCollector,
routeObjectCollector,
} from './collectors';
import { validateRoutes } from './validation';
import { useRouteRef, RoutingProvider } from './hooks';
import { createRouteRef, RouteRefConfig } from './RouteRef';
import { RouteResolver } from './RouteResolver';
import { createExternalRouteRef } from './ExternalRouteRef';
import { AnyRouteRef, RouteFunc, RouteRef, ExternalRouteRef } from './types';
import { AnyRouteRef, RouteFunc } from './types';
const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
path: '/unused',
title: 'Unused',
...extra,
});
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
<>{children}</>
);
const plugin = createPlugin({ id: 'my-plugin' });
const ref1 = createRouteRef(mockConfig({ path: '/wat1' }));
const ref2 = createRouteRef(mockConfig({ path: '/wat2' }));
const ref3 = createRouteRef(mockConfig({ path: '/wat3' }));
const ref4 = createRouteRef(mockConfig({ path: '/wat4' }));
const ref5 = createRouteRef({
...mockConfig({ path: '/wat5' }),
params: ['x'],
});
const ref1 = createRouteRef({ id: 'ref1' });
const ref2 = createRouteRef({ id: 'ref2' });
const ref3 = createRouteRef({ id: 'ref3' });
const ref4 = createRouteRef({ id: 'ref4' });
const ref5 = createRouteRef({ id: 'ref5', params: ['x'] });
const eRefA = createExternalRouteRef({ id: '1' });
const eRefB = createExternalRouteRef({ id: '2' });
const eRefC = createExternalRouteRef({ id: '3', params: ['y'] });
@@ -14,74 +14,21 @@
* limitations under the License.
*/
import React, {
createContext,
ReactNode,
useContext,
useMemo,
Context,
} from 'react';
import { useLocation } from 'react-router-dom';
import React, { createContext, ReactNode } from 'react';
import { ExternalRouteRef, RouteRef, SubRouteRef } from '@backstage/plugin-api';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
import {
BackstageRouteObject,
RouteRef,
ExternalRouteRef,
AnyParams,
SubRouteRef,
RouteFunc,
} from './types';
import { RouteResolver } from './RouteResolver';
import {
VersionedValue,
createVersionedValueMap,
VersionedValue,
} from '../lib/versionedValues';
import {
getGlobalSingleton,
getOrCreateGlobalSingleton,
} from '../lib/globalObject';
import { RouteResolver } from './RouteResolver';
import { BackstageRouteObject } from './types';
type RoutingContextType = VersionedValue<{ 1: RouteResolver }> | undefined;
const RoutingContext = getOrCreateGlobalSingleton('routing-context', () =>
createContext<RoutingContextType>(undefined),
);
export function useRouteRef<Optional extends boolean, Params extends AnyParams>(
routeRef: ExternalRouteRef<Params, Optional>,
): Optional extends true ? RouteFunc<Params> | undefined : RouteFunc<Params>;
export function useRouteRef<Params extends AnyParams>(
routeRef: RouteRef<Params> | SubRouteRef<Params>,
): RouteFunc<Params>;
export function useRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
): RouteFunc<Params> | undefined {
const sourceLocation = useLocation();
const versionedContext = useContext(
getGlobalSingleton<Context<RoutingContextType>>('routing-context'),
);
const resolver = versionedContext?.atVersion(1);
const routeFunc = useMemo(
() => resolver && resolver.resolve(routeRef, sourceLocation),
[resolver, routeRef, sourceLocation],
);
if (!versionedContext) {
throw new Error('useRouteRef used outside of routing context');
}
if (!resolver) {
throw new Error('RoutingContext v1 not available');
}
const isOptional = 'optional' in routeRef && routeRef.optional;
if (!routeFunc && !isOptional) {
throw new Error(`No path for ${routeRef}`);
}
return routeFunc;
}
type ProviderProps = {
routePaths: Map<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
@@ -1,134 +0,0 @@
/*
* 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 { AnyParams, SubRouteRef } from './types';
import { createSubRouteRef, isSubRouteRef } from './SubRouteRef';
import { createRouteRef, isRouteRef } from './RouteRef';
import { isExternalRouteRef } from './ExternalRouteRef';
const parent = createRouteRef({ id: 'parent' });
const parentX = createRouteRef({ id: 'parent-x', params: ['x'] });
describe('SubRouteRef', () => {
it('should be created', () => {
const routeRef: SubRouteRef<undefined> = createSubRouteRef({
parent,
id: 'my-route-ref',
path: '/foo',
});
expect(routeRef.path).toBe('/foo');
expect(routeRef.parent).toBe(parent);
expect(routeRef.params).toEqual([]);
expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}');
expect(isRouteRef(routeRef)).toBe(false);
expect(isSubRouteRef(routeRef)).toBe(true);
expect(isExternalRouteRef(routeRef)).toBe(false);
expect(isRouteRef({} as SubRouteRef)).toBe(false);
});
it('should be created with params', () => {
const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({
parent,
id: 'my-other-route-ref',
path: '/foo/:bar',
});
expect(routeRef.path).toBe('/foo/:bar');
expect(routeRef.parent).toBe(parent);
expect(routeRef.params).toEqual(['bar']);
});
it('should be created with merged params', () => {
const routeRef: SubRouteRef<{
x: string;
y: string;
z: string;
}> = createSubRouteRef({
parent: parentX,
id: 'my-other-route-ref',
path: '/foo/:y/:z',
});
expect(routeRef.path).toBe('/foo/:y/:z');
expect(routeRef.parent).toBe(parentX);
expect(routeRef.params).toEqual(['x', 'y', 'z']);
});
it('should be created with params from parent', () => {
const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({
parent: parentX,
id: 'my-other-route-ref',
path: '/foo/bar',
});
expect(routeRef.path).toBe('/foo/bar');
expect(routeRef.parent).toBe(parentX);
expect(routeRef.params).toEqual(['x']);
});
it.each([
['foo', "SubRouteRef path must start with '/', got 'foo'"],
[':foo', "SubRouteRef path must start with '/', got ':foo'"],
['', "SubRouteRef path must start with '/', got ''"],
['/', "SubRouteRef path must not end with '/', got '/'"],
['/foo/', "SubRouteRef path must not end with '/', got '/foo/'"],
['/foo/:x', 'SubRouteRef may not have params that overlap with its parent'],
['/:/foo', "SubRouteRef path has invalid param, got ''"],
['/:inva:lid/foo', "SubRouteRef path has invalid param, got 'inva:lid'"],
['/:inva=lid/foo', "SubRouteRef path has invalid param, got 'inva=lid'"],
])('should throw if path is invalid, %s', (path, message) => {
expect(() =>
createSubRouteRef({ path, parent: parentX, id: path }),
).toThrow(message);
});
it('should properly infer and parse path parameters', () => {
function validateType<T extends AnyParams>(_ref: SubRouteRef<T>) {}
const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' });
// @ts-expect-error
validateType<{ x: string }>(_1);
validateType<undefined>(_1);
const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' });
// @ts-expect-error
validateType<undefined>(_2);
// @ts-expect-error
validateType<{ x: string; z: string }>(_2);
// @ts-expect-error
validateType<{ y: string }>(_2);
// TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead
validateType<{ x: string; y: string; z: string }>(_2);
validateType<{ x: string; y: string }>(_2);
const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' });
// @ts-expect-error
validateType<undefined>(_3);
// @ts-expect-error
validateType<{ y: string }>(_3);
validateType<{ x: string }>(_3);
const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' });
// @ts-expect-error
validateType<undefined>(_4);
// @ts-expect-error
validateType<{ x: string; z: string }>(_4);
// @ts-expect-error
validateType<{ y: string }>(_4);
validateType<{ x: string; y: string }>(_4);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
});
});
-128
View File
@@ -1,128 +0,0 @@
/*
* 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 {
AnyParams,
ExternalRouteRef,
OptionalParams,
ParamKeys,
RouteRef,
routeRefType,
SubRouteRef,
} from './types';
// Should match the pattern in react-router
const PARAM_PATTERN = /^\w+$/;
export class SubRouteRefImpl<Params extends AnyParams>
implements SubRouteRef<Params> {
readonly [routeRefType] = 'sub';
constructor(
private readonly id: string,
readonly path: string,
readonly parent: RouteRef,
readonly params: ParamKeys<Params>,
) {}
toString() {
return `routeRef{type=sub,id=${this.id}}`;
}
}
// These utility types help us infer a Param object type from a string path
// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }`
type ParamPart<S extends string> = S extends `:${infer Param}` ? Param : never;
type ParamNames<S extends string> = S extends `${infer Part}/${infer Rest}`
? ParamPart<Part> | ParamNames<Rest>
: ParamPart<S>;
type PathParams<S extends string> = { [name in ParamNames<S>]: string };
/**
* Merges a param object type with with an optional params type into a params object
*/
type MergeParams<
P1 extends { [param in string]: string },
P2 extends AnyParams
> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2);
/**
* Creates a SubRouteRef type given the desired parameters and parent route parameters.
* The parameters types are merged together while ensuring that there is no overlap between the two.
*/
type MakeSubRouteRef<
Params extends { [param in string]: string },
ParentParams extends AnyParams
> = keyof Params & keyof ParentParams extends never
? SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>
: never;
export function createSubRouteRef<
Path extends string,
ParentParams extends AnyParams = never
>(config: {
id: string;
path: Path;
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams> {
const { id, path, parent } = config;
type Params = PathParams<Path>;
// Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz'
const pathParams = path
.split('/')
.filter(p => p.startsWith(':'))
.map(p => p.substring(1));
const params = [...parent.params, ...pathParams];
if (parent.params.some(p => pathParams.includes(p as string))) {
throw new Error(
'SubRouteRef may not have params that overlap with its parent',
);
}
if (!path.startsWith('/')) {
throw new Error(`SubRouteRef path must start with '/', got '${path}'`);
}
if (path.endsWith('/')) {
throw new Error(`SubRouteRef path must not end with '/', got '${path}'`);
}
for (const param of pathParams) {
if (!PARAM_PATTERN.test(param)) {
throw new Error(`SubRouteRef path has invalid param, got '${param}'`);
}
}
// We ensure that the type of the return type is sane here
const subRouteRef = new SubRouteRefImpl(
id,
path,
parent,
params as ParamKeys<MergeParams<Params, ParentParams>>,
) as SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>;
// But skip type checking of the return value itself, because the conditional
// type checking of the parent parameter overlap is tricky to express.
return subRouteRef as any;
}
export function isSubRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
): routeRef is SubRouteRef<Params> {
return routeRef[routeRefType] === 'sub';
}
@@ -26,11 +26,9 @@ import {
childDiscoverer,
routeElementDiscoverer,
} from '../extensions/traversal';
import { createRouteRef } from './RouteRef';
import { createPlugin } from '../plugin';
import { createRouteRef, createPlugin, RouteRef } from '@backstage/plugin-api';
import { attachComponentData, createRoutableExtension } from '../extensions';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { RouteRef } from './types';
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
<>{children}</>
@@ -38,11 +36,11 @@ const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
const plugin = createPlugin({ id: 'my-plugin' });
const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' });
const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' });
const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' });
const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' });
const ref5 = createRouteRef({ path: '/foo5', title: 'Foo' });
const ref1 = createRouteRef({ id: 'ref1' });
const ref2 = createRouteRef({ id: 'ref2' });
const ref3 = createRouteRef({ id: 'ref3' });
const ref4 = createRouteRef({ id: 'ref4' });
const ref5 = createRouteRef({ id: 'ref5' });
const refOrder = [ref1, ref2, ref3, ref4, ref5];
const Extension1 = plugin.provide(
+2 -1
View File
@@ -15,7 +15,8 @@
*/
import { isValidElement, ReactElement, ReactNode } from 'react';
import { BackstageRouteObject, RouteRef } from '../routing/types';
import { RouteRef } from '@backstage/plugin-api';
import { BackstageRouteObject } from '../routing/types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
-12
View File
@@ -14,16 +14,4 @@
* limitations under the License.
*/
export type {
RouteRef,
AbsoluteRouteRef,
ConcreteRoute,
MutableRouteRef,
ExternalRouteRef,
} from './types';
export { FlatRoutes } from './FlatRoutes';
export { createRouteRef } from './RouteRef';
export { createSubRouteRef } from './SubRouteRef';
export { createExternalRouteRef } from './ExternalRouteRef';
export type { RouteRefConfig } from './RouteRef';
export { useRouteRef } from './hooks';
+11 -60
View File
@@ -14,16 +14,20 @@
* limitations under the License.
*/
import { IconComponent } from '@backstage/plugin-api';
import { RouteRef, SubRouteRef, ExternalRouteRef } from '@backstage/plugin-api';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
export const routeRefType: unique symbol = getOrCreateGlobalSingleton<any>(
'route-ref-type',
() => Symbol('route-ref-type'),
);
export type AnyParams = { [param in string]: string } | undefined;
export type ParamKeys<Params extends AnyParams> = keyof Params extends never
? []
: (keyof Params)[];
export type OptionalParams<
Params extends { [param in string]: string }
> = Params[keyof Params] extends never ? undefined : Params;
export type AnyRouteRef =
| RouteRef<any>
| SubRouteRef<any>
| ExternalRouteRef<any, any>;
// 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.
@@ -34,59 +38,6 @@ export type RouteFunc<Params extends AnyParams> = (
...[params]: Params extends undefined ? readonly [] : readonly [Params]
) => string;
export const routeRefType: unique symbol = getOrCreateGlobalSingleton<any>(
'route-ref-type',
() => Symbol('route-ref-type'),
);
export type RouteRef<Params extends AnyParams = any> = {
readonly [routeRefType]: 'absolute';
params: ParamKeys<Params>;
// TODO(Rugvip): Remove all of these once plugins don't rely on the path
/** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */
path: string;
/** @deprecated icons are no longer accessed via RouteRefs */
icon?: IconComponent;
/** @deprecated titles are no longer accessed via RouteRefs */
title?: string;
};
export type SubRouteRef<Params extends AnyParams = any> = {
readonly [routeRefType]: 'sub';
parent: RouteRef;
path: string;
params: ParamKeys<Params>;
};
export type ExternalRouteRef<
Params extends AnyParams = any,
Optional extends boolean = any
> = {
readonly [routeRefType]: 'external';
params: ParamKeys<Params>;
optional?: Optional;
};
export type AnyRouteRef =
| RouteRef<any>
| SubRouteRef<any>
| ExternalRouteRef<any, any>;
// TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release
/** @deprecated */
export type ConcreteRoute = {};
/** @deprecated */
export type AbsoluteRouteRef = RouteRef<{}>;
/** @deprecated */
export type MutableRouteRef = RouteRef<{}>;
// A duplicate of the react-router RouteObject, but with routeRef added
export interface BackstageRouteObject {
caseSensitive: boolean;