diff --git a/.changeset/cuddly-yaks-sneeze.md b/.changeset/cuddly-yaks-sneeze.md new file mode 100644 index 0000000000..47d24d1cdb --- /dev/null +++ b/.changeset/cuddly-yaks-sneeze.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-api': patch +--- + +Improve forwards compatibility with `@backstage/core-app-api` and `@backstage/core-plugin-api` by re-using route reference types and factory methods from `@backstage/core-plugin-api`. diff --git a/.changeset/early-baboons-double.md b/.changeset/early-baboons-double.md new file mode 100644 index 0000000000..efbf0f559e --- /dev/null +++ b/.changeset/early-baboons-double.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Fixes a type bug where supplying all app icons to `createApp` was required, rather than just a partial list. diff --git a/.changeset/silent-lions-collect.md b/.changeset/silent-lions-collect.md new file mode 100644 index 0000000000..76747871ef --- /dev/null +++ b/.changeset/silent-lions-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Update `createApp` options to allow plugins with unknown output types in order to improve forwards and backwards compatibility. diff --git a/packages/core-api/src/routing/ExternalRouteRef.ts b/packages/core-api/src/routing/ExternalRouteRef.ts index e6af9cf8a7..af61ad8dfc 100644 --- a/packages/core-api/src/routing/ExternalRouteRef.ts +++ b/packages/core-api/src/routing/ExternalRouteRef.ts @@ -20,56 +20,9 @@ import { ExternalRouteRef, routeRefType, AnyParams, - ParamKeys, - OptionalParams, } from './types'; -export class ExternalRouteRefImpl< - Params extends AnyParams, - Optional extends boolean -> implements ExternalRouteRef { - readonly [routeRefType] = 'external'; - - constructor( - private readonly id: string, - readonly params: ParamKeys, - 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, Optional> { - return new ExternalRouteRefImpl( - options.id, - (options.params ?? []) as ParamKeys>, - Boolean(options.optional) as Optional, - ); -} +export { createExternalRouteRef } from '@backstage/core-plugin-api'; export function isExternalRouteRef< Params extends AnyParams, diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 4603b03a30..a6a8d60326 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -21,10 +21,11 @@ import { routeRefType, AnyParams, ParamKeys, - OptionalParams, } from './types'; import { IconComponent } from '../icons/types'; +export { createRouteRef } from '@backstage/core-plugin-api'; + // TODO(Rugvip): Remove this in the next breaking release, it's exported but unused export type RouteRefConfig = { params?: ParamKeys; @@ -33,69 +34,6 @@ export type RouteRefConfig = { title: string; }; -export class RouteRefImpl - implements RouteRef { - readonly [routeRefType] = 'absolute'; - - constructor( - private readonly id: string, - readonly params: ParamKeys, - 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> { - 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>, - config, - ); -} - export function isRouteRef( routeRef: | RouteRef diff --git a/packages/core-api/src/routing/SubRouteRef.ts b/packages/core-api/src/routing/SubRouteRef.ts index 7ddfc89c80..61d3450e27 100644 --- a/packages/core-api/src/routing/SubRouteRef.ts +++ b/packages/core-api/src/routing/SubRouteRef.ts @@ -17,106 +17,12 @@ import { AnyParams, ExternalRouteRef, - OptionalParams, - ParamKeys, RouteRef, routeRefType, SubRouteRef, } from './types'; -// Should match the pattern in react-router -const PARAM_PATTERN = /^\w+$/; - -export class SubRouteRefImpl - implements SubRouteRef { - readonly [routeRefType] = 'sub'; - - constructor( - private readonly id: string, - readonly path: string, - readonly parent: RouteRef, - readonly params: ParamKeys, - ) {} - - 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 `:${infer Param}` ? Param : never; -type ParamNames = S extends `${infer Part}/${infer Rest}` - ? ParamPart | ParamNames - : ParamPart; -type PathParams = { [name in ParamNames]: 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>> - : never; - -export function createSubRouteRef< - Path extends string, - ParentParams extends AnyParams = never ->(config: { - id: string; - path: Path; - parent: RouteRef; -}): MakeSubRouteRef, ParentParams> { - const { id, path, parent } = config; - type Params = PathParams; - - // 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>, - ) as SubRouteRef>>; - - // 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 { createSubRouteRef } from '@backstage/core-plugin-api'; export function isSubRouteRef( routeRef: diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index e88b2f748c..0e5a786770 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -14,9 +14,14 @@ * limitations under the License. */ -import { IconComponent } from '../icons/types'; +import { + RouteRef, + SubRouteRef, + ExternalRouteRef, +} from '@backstage/core-plugin-api'; import { getOrCreateGlobalSingleton } from '../lib/globalObject'; -import { RouteRef as NewRouteRef } from '@backstage/core-plugin-api'; + +export type { RouteRef, SubRouteRef, ExternalRouteRef }; export type AnyParams = { [param in string]: string } | undefined; export type ParamKeys = keyof Params extends never @@ -36,7 +41,7 @@ export type RouteFunc = ( ) => string; type RouteRefType = Exclude< - keyof NewRouteRef, + keyof RouteRef, 'params' | 'path' | 'title' | 'icon' >; export const routeRefType: RouteRefType = getOrCreateGlobalSingleton( @@ -44,41 +49,6 @@ export const routeRefType: RouteRefType = getOrCreateGlobalSingleton( () => Symbol('route-ref-type'), ); -export type RouteRef = { - readonly [routeRefType]: 'absolute'; - - params: ParamKeys; - - // 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 = { - readonly [routeRefType]: 'sub'; - - parent: RouteRef; - - path: string; - - params: ParamKeys; -}; - -export type ExternalRouteRef< - Params extends AnyParams = any, - Optional extends boolean = any -> = { - readonly [routeRefType]: 'external'; - - params: ParamKeys; - - optional?: Optional; -}; - export type AnyRouteRef = | RouteRef | SubRouteRef diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index c64f9db999..b85c9392d7 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -42,6 +42,7 @@ import { oktaAuthApiRef } from '@backstage/core-plugin-api'; import { oneloginAuthApiRef } from '@backstage/core-plugin-api'; import { OpenIdConnectApi } from '@backstage/core-plugin-api'; import { PendingAuthRequest } from '@backstage/core-plugin-api'; +import { PluginOutput } from '@backstage/core-plugin-api'; import { ProfileInfo } from '@backstage/core-plugin-api'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; @@ -137,10 +138,10 @@ export type AppContext = { // @public (undocumented) export type AppOptions = { apis?: Iterable; - icons?: AppIcons & { + icons?: Partial & { [key in string]: IconComponent; }; - plugins?: BackstagePlugin[]; + plugins?: BackstagePluginWithAnyOutput[]; components?: Partial; themes?: AppTheme[]; configLoader?: AppConfigLoader; @@ -183,6 +184,11 @@ export type BackstageApp = { getRouter(): ComponentType<{}>; }; +// @public (undocumented) +export type BackstagePluginWithAnyOutput = Omit, 'output'> & { + output(): (PluginOutput | UnknownPluginOutput)[]; +}; + // @public (undocumented) export type BootErrorPageProps = { step: 'load-config' | 'load-chunk'; diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx index b93df63ff0..1670978791 100644 --- a/packages/core-app-api/src/app/createApp.tsx +++ b/packages/core-app-api/src/app/createApp.tsx @@ -35,6 +35,7 @@ import { BootErrorPageProps, ErrorBoundaryFallbackProps, } from './types'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; /** * The default config loader, which expects that config is available at compile-time @@ -171,7 +172,7 @@ export function createApp(options?: AppOptions) { return new PrivateAppImpl({ apis, icons, - plugins, + plugins: plugins as BackstagePlugin[], components, themes, configLoader, diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index b7a6754232..cf5811beae 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -24,6 +24,7 @@ import { RouteRef, SubRouteRef, ExternalRouteRef, + PluginOutput, } from '@backstage/core-plugin-api'; import { AppConfig } from '@backstage/config'; import { AppIcons } from './icons'; @@ -131,6 +132,21 @@ export type AppRouteBinder = < >, ) => void; +// Output from newer or older plugin API versions that might not be supported by +// this version of the app API, but we don't want to break at the type checking level. +// We only use this more permissive type for the `createApp` options, as we otherwise +// want to stick to using the type for the outputs that we know about in this version +// of the app api. +type UnknownPluginOutput = { + type: string; +}; +export type BackstagePluginWithAnyOutput = Omit< + BackstagePlugin, + 'output' +> & { + output(): (PluginOutput | UnknownPluginOutput)[]; +}; + export type AppOptions = { /** * A collection of ApiFactories to register in the application to either @@ -141,12 +157,12 @@ export type AppOptions = { /** * Supply icons to override the default ones. */ - icons?: AppIcons & { [key in string]: IconComponent }; + icons?: Partial & { [key in string]: IconComponent }; /** * A list of all plugins to include in the app. */ - plugins?: BackstagePlugin[]; + plugins?: BackstagePluginWithAnyOutput[]; /** * Supply components to the app to override the default ones.