From a70e6937076096d529b6a33c7b7da09f8f0e1ab9 Mon Sep 17 00:00:00 2001 From: Philipp Hugenroth Date: Mon, 6 Nov 2023 15:55:31 +0100 Subject: [PATCH] Copy over missing APIs Co-authored-by: Camila Belo Signed-off-by: Philipp Hugenroth --- .../frontend-app-api/src/wiring/createApp.tsx | 94 +++---- .../src/system/ApiAggregator.test.ts | 46 +++ .../src/system/ApiAggregator.ts | 39 +++ .../src/system/ApiFactoryRegistry.test.ts | 91 ++++++ .../src/system/ApiFactoryRegistry.ts | 95 +++++++ .../src/system/ApiProvider.test.tsx | 234 ++++++++++++++++ .../src/system/ApiProvider.tsx | 53 ++++ .../src/system/ApiRegistry.test.ts | 75 +++++ .../src/system/ApiRegistry.ts | 80 ++++++ .../src/system/ApiResolver.test.ts | 263 ++++++++++++++++++ .../src/system/ApiResolver.ts | 116 ++++++++ .../frontend-plugin-api/src/system/index.ts | 3 + .../frontend-plugin-api/src/system/types.ts | 9 + 13 files changed, 1150 insertions(+), 48 deletions(-) create mode 100644 packages/frontend-plugin-api/src/system/ApiAggregator.test.ts create mode 100644 packages/frontend-plugin-api/src/system/ApiAggregator.ts create mode 100644 packages/frontend-plugin-api/src/system/ApiFactoryRegistry.test.ts create mode 100644 packages/frontend-plugin-api/src/system/ApiFactoryRegistry.ts create mode 100644 packages/frontend-plugin-api/src/system/ApiProvider.test.tsx create mode 100644 packages/frontend-plugin-api/src/system/ApiProvider.tsx create mode 100644 packages/frontend-plugin-api/src/system/ApiRegistry.test.ts create mode 100644 packages/frontend-plugin-api/src/system/ApiRegistry.ts create mode 100644 packages/frontend-plugin-api/src/system/ApiResolver.test.ts create mode 100644 packages/frontend-plugin-api/src/system/ApiResolver.ts diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index a857eff27d..59b62f3051 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -14,49 +14,67 @@ * limitations under the License. */ -import React, { JSX } from 'react'; -import { ConfigReader, Config } from '@backstage/config'; -import { - AppTree, - appTreeApiRef, - BackstagePlugin, - coreExtensionData, - ExtensionDataRef, - ExtensionOverrides, - RouteRef, - useRouteRef, -} from '@backstage/frontend-plugin-api'; -import { Core } from '../extensions/Core'; -import { CoreRoutes } from '../extensions/CoreRoutes'; -import { CoreLayout } from '../extensions/CoreLayout'; -import { CoreNav } from '../extensions/CoreNav'; +import { Config, ConfigReader } from '@backstage/config'; +import { SidebarItem } from '@backstage/core-components'; import { AnyApiFactory, ApiHolder, AppComponents, AppContext, + BackstagePlugin as LegacyBackstagePlugin, attachComponentData, } from '@backstage/core-plugin-api'; import { - appThemeApiRef, - ConfigApi, - configApiRef, - IconComponent, - BackstagePlugin as LegacyBackstagePlugin, - featureFlagsApiRef, - identityApiRef, - AppTheme, -} from '@backstage/frontend-plugin-api'; -import { getAvailableFeatures } from './discovery'; + appLanguageApiRef, + translationApiRef, +} from '@backstage/core-plugin-api/alpha'; import { ApiFactoryRegistry, ApiProvider, ApiResolver, - AppThemeSelector, -} from '@backstage/core-app-api'; + AppNode, + AppTheme, + AppTree, + BackstagePlugin, + ConfigApi, + ExtensionDataRef, + ExtensionOverrides, + IconComponent, + RouteRef, + appThemeApiRef, + appTreeApiRef, + configApiRef, + coreExtensionData, + featureFlagsApiRef, + identityApiRef, + useRouteRef, +} from '@backstage/frontend-plugin-api'; +import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; +import { JSX, default as React } from 'react'; +import { BrowserRouter, Route } from 'react-router-dom'; +import { Core } from '../extensions/Core'; +import { CoreLayout } from '../extensions/CoreLayout'; +import { CoreNav } from '../extensions/CoreNav'; +import { CoreRoutes } from '../extensions/CoreRoutes'; +import { DarkTheme, LightTheme } from '../extensions/themes'; +import { AppRouteBinder } from '../routing'; +import { RoutingProvider } from '../routing/RoutingProvider'; +import { collectRouteIds } from '../routing/collectRouteIds'; +import { extractRouteInfoFromAppNode } from '../routing/extractRouteInfoFromAppNode'; +import { resolveRouteBindings } from '../routing/resolveRouteBindings'; +import { createAppTree } from '../tree'; +import { getAvailableFeatures } from './discovery'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + apis as defaultApis, + components as defaultComponents, + icons as defaultIcons, +} from '../../../app-defaults/src/defaults'; // TODO: Get rid of all of these // eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppThemeSelector } from '../../../core-app-api/src/apis/implementations/AppThemeApi'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppThemeProvider } from '../../../core-app-api/src/app/AppThemeProvider'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; @@ -73,26 +91,6 @@ import { AppLanguageSelector } from '../../../core-app-api/src/apis/implementati // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { I18nextTranslationApi } from '../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { - apis as defaultApis, - components as defaultComponents, - icons as defaultIcons, -} from '../../../app-defaults/src/defaults'; -import { BrowserRouter, Route } from 'react-router-dom'; -import { SidebarItem } from '@backstage/core-components'; -import { DarkTheme, LightTheme } from '../extensions/themes'; -import { extractRouteInfoFromAppNode } from '../routing/extractRouteInfoFromAppNode'; -import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; -import { - appLanguageApiRef, - translationApiRef, -} from '@backstage/core-plugin-api/alpha'; -import { AppRouteBinder } from '../routing'; -import { RoutingProvider } from '../routing/RoutingProvider'; -import { resolveRouteBindings } from '../routing/resolveRouteBindings'; -import { collectRouteIds } from '../routing/collectRouteIds'; -import { createAppTree } from '../tree'; -import { AppNode } from '@backstage/frontend-plugin-api'; const builtinExtensions = [ Core, diff --git a/packages/frontend-plugin-api/src/system/ApiAggregator.test.ts b/packages/frontend-plugin-api/src/system/ApiAggregator.test.ts new file mode 100644 index 0000000000..c2754878f2 --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiAggregator.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 '@backstage/core-plugin-api'; +import { ApiAggregator } from './ApiAggregator'; +import { ApiRegistry } from './ApiRegistry'; + +describe('ApiAggregator', () => { + const apiARef = createApiRef({ id: 'a' }); + const apiBRef = createApiRef({ id: 'b' }); + + it('should forward implementations', () => { + const agg = new ApiAggregator( + ApiRegistry.from([ + [apiARef, 5], + [apiBRef, 10], + ]), + ); + expect(agg.get(apiARef)).toBe(5); + expect(agg.get(apiBRef)).toBe(10); + }); + + it('should return the first implementation', () => { + const agg = new ApiAggregator( + ApiRegistry.from([ + [apiARef, 1], + [apiARef, 2], + ]), + ); + expect(agg.get(apiARef)).toBe(2); + expect(agg.get(apiBRef)).toBe(undefined); + }); +}); diff --git a/packages/frontend-plugin-api/src/system/ApiAggregator.ts b/packages/frontend-plugin-api/src/system/ApiAggregator.ts new file mode 100644 index 0000000000..a6b4471f8c --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiAggregator.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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, ApiHolder } from '@backstage/core-plugin-api'; + +/** + * An ApiHolder that queries multiple other holders from for + * an Api implementation, returning the first one encountered.. + */ +export class ApiAggregator implements ApiHolder { + private readonly holders: ApiHolder[]; + + constructor(...holders: ApiHolder[]) { + this.holders = holders; + } + + get(apiRef: ApiRef): T | undefined { + for (const holder of this.holders) { + const api = holder.get(apiRef); + if (api) { + return api; + } + } + return undefined; + } +} diff --git a/packages/frontend-plugin-api/src/system/ApiFactoryRegistry.test.ts b/packages/frontend-plugin-api/src/system/ApiFactoryRegistry.test.ts new file mode 100644 index 0000000000..e859e8a491 --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiFactoryRegistry.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 '@backstage/core-plugin-api'; +import { ApiFactoryRegistry } from './ApiFactoryRegistry'; + +const aRef = createApiRef({ id: 'a' }); +const aFactory1 = { api: aRef, deps: {}, factory: () => 1 }; +const aFactory2 = { api: aRef, deps: {}, factory: () => 2 }; +const bRef = createApiRef({ id: 'b' }); +const bFactory = { api: bRef, deps: {}, factory: () => 'x' }; +const cRef = createApiRef({ id: 'c' }); +const cFactory = { api: cRef, deps: {}, factory: () => 'y' }; + +describe('ApiFactoryRegistry', () => { + it('should be empty when created', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.getAllApis()).toEqual(new Set()); + }); + + it('should register a factory', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.getAllApis()).toEqual(new Set([aRef])); + }); + + it('should prioritize factories based on scope', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('default', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('app', aFactory2)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory2); + expect(registry.register('default', aFactory1)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory2); + expect(registry.register('static', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('static', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('app', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.getAllApis()).toEqual(new Set([aRef])); + }); + + it('should register multiple factories without conflict', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('static', aFactory1)).toBe(true); + expect(registry.register('default', bFactory)).toBe(true); + expect(registry.register('app', cFactory)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.get(bRef)).toBe(bFactory); + expect(registry.get(cRef)).toBe(cFactory); + expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef])); + }); + + it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => { + const ref1 = createApiRef({ id: 'a' }); + const ref2 = createApiRef({ id: 'a' }); + + const factory1 = { api: ref1, deps: {}, factory: () => 3 }; + const factory2 = { api: ref2, deps: {}, factory: () => 3 }; + + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', factory1)).toBe(true); + expect(registry.register('default', factory2)).toBe(false); + expect(registry.get(ref1)).toEqual(factory1); + expect(registry.get(ref2)).toEqual(factory1); + expect(registry.getAllApis()).toEqual(new Set([ref1])); + + expect(registry.register('app', factory2)).toBe(true); + expect(registry.get(ref1)).toEqual(factory2); + expect(registry.get(ref2)).toEqual(factory2); + expect(Array.from(registry.getAllApis())[0]).toBe(ref2); + expect(Array.from(registry.getAllApis())[0]).not.toBe(ref1); + }); +}); diff --git a/packages/frontend-plugin-api/src/system/ApiFactoryRegistry.ts b/packages/frontend-plugin-api/src/system/ApiFactoryRegistry.ts new file mode 100644 index 0000000000..5f56793cae --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiFactoryRegistry.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ApiFactoryHolder } from './types'; +import { + ApiRef, + ApiFactory, + AnyApiRef, + AnyApiFactory, +} from '@backstage/core-plugin-api'; + +/** + * Scope type when registering API factories. + * @public + */ +export type ApiFactoryScope = + | 'default' // Default factories registered by core and plugins + | 'app' // Factories registered in the app, overriding default ones + | 'static'; // APIs that can't be overridden, e.g. config + +enum ScopePriority { + default = 10, + app = 50, + static = 100, +} + +type FactoryTuple = { + priority: number; + factory: AnyApiFactory; +}; + +/** + * ApiFactoryRegistry is an ApiFactoryHolder implementation that enables + * registration of API Factories with different scope. + * + * Each scope has an assigned priority, where factories registered with + * higher priority scopes override ones with lower priority. + * + * @public + */ +export class ApiFactoryRegistry implements ApiFactoryHolder { + private readonly factories = new Map(); + + /** + * Register a new API factory. Returns true if the factory was added + * to the registry. + * + * A factory will not be added to the registry if there is already + * an existing factory with the same or higher priority. + */ + register( + scope: ApiFactoryScope, + factory: ApiFactory, + ) { + const priority = ScopePriority[scope]; + const existing = this.factories.get(factory.api.id); + if (existing && existing.priority >= priority) { + return false; + } + + this.factories.set(factory.api.id, { priority, factory }); + return true; + } + + get( + api: ApiRef, + ): ApiFactory | undefined { + const tuple = this.factories.get(api.id); + if (!tuple) { + return undefined; + } + return tuple.factory as ApiFactory; + } + + getAllApis(): Set { + const refs = new Set(); + for (const { factory } of this.factories.values()) { + refs.add(factory.api); + } + return refs; + } +} diff --git a/packages/frontend-plugin-api/src/system/ApiProvider.test.tsx b/packages/frontend-plugin-api/src/system/ApiProvider.test.tsx new file mode 100644 index 0000000000..6460e12900 --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiProvider.test.tsx @@ -0,0 +1,234 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 from 'react'; +import { + useApi, + createApiRef, + withApis, + ApiHolder, + ApiRef, +} from '@backstage/core-plugin-api'; +import { ApiProvider } from './ApiProvider'; +import { ApiRegistry } from './ApiRegistry'; +import { render } from '@testing-library/react'; +import { withLogCollector } from '@backstage/test-utils'; +import { useVersionedContext } from '@backstage/version-bridge'; + +describe('ApiProvider', () => { + type Api = () => string; + const apiRef = createApiRef({ id: 'x' }); + const registry = ApiRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumer = () => { + const api = useApi(apiRef); + return

hook message: {api()}

; + }; + + const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => { + return

hoc message: {getMessage()}

; + }); + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + + const renderedHoc = render( + + + , + ); + renderedHoc.getByText('hoc message: hello'); + }); + + it('should provide nested access to apis', () => { + const aRef = createApiRef({ id: 'a' }); + const bRef = createApiRef({ id: 'b' }); + + const MyComponent = () => { + const a = useApi(aRef); + const b = useApi(bRef); + return ( +
+ a={a} b={b} +
+ ); + }; + + const renderedHook = render( + + + + + , + ); + renderedHook.getByText('a=z b=y'); + }); + + it('should ignore deps in prototype', () => { + // 100% coverage + happy typescript = hasOwnProperty + this atrocity + const xRef = createApiRef({ id: 'x' }); + + const proto = { x: xRef }; + const props = { getMessage: { enumerable: true, value: apiRef } }; + const obj = Object.create(proto, props) as { + getMessage: typeof apiRef; + x: typeof xRef; + }; + + const MyWeirdHocConsumer = withApis(obj)(({ getMessage }) => { + return

hoc message: {getMessage()}

; + }); + + const renderedHoc = render( + + + , + ); + renderedHoc.getByText('hoc message: hello'); + }); + + it('should error if no provider is available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render(); + }).toThrow(/^API context is not available/); + }).error, + ).toEqual([ + expect.objectContaining({ + detail: new Error('API context is not available'), + type: 'unhandled exception', + }), + expect.objectContaining({ + detail: new Error('API context is not available'), + type: 'unhandled exception', + }), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render(); + }).toThrow(/^API context is not available/); + }).error, + ).toEqual([ + expect.objectContaining({ + detail: new Error('API context is not available'), + type: 'unhandled exception', + }), + expect.objectContaining({ + detail: new Error('API context is not available'), + type: 'unhandled exception', + }), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + }); + + it('should error if api is not available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).toThrow('No implementation available for apiRef{x}'); + }).error, + ).toEqual([ + expect.objectContaining({ + detail: new Error('No implementation available for apiRef{x}'), + type: 'unhandled exception', + }), + expect.objectContaining({ + detail: new Error('No implementation available for apiRef{x}'), + type: 'unhandled exception', + }), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).toThrow('No implementation available for apiRef{x}'); + }).error, + ).toEqual([ + expect.objectContaining({ + detail: new Error('No implementation available for apiRef{x}'), + type: 'unhandled exception', + }), + expect.objectContaining({ + detail: new Error('No implementation available for apiRef{x}'), + type: 'unhandled exception', + }), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + }); +}); + +describe('v1 consumer', () => { + function useMockApiV1(apiRef: ApiRef): T { + const impl = useVersionedContext<{ 1: ApiHolder }>('api-context') + ?.atVersion(1) + ?.get(apiRef); + if (!impl) { + throw new Error('no impl'); + } + return impl; + } + + type Api = () => string; + const apiRef = createApiRef({ id: 'x' }); + const registry = ApiRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumerV1 = () => { + const api = useMockApiV1(apiRef); + return

hook message: {api()}

; + }; + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + }); +}); diff --git a/packages/frontend-plugin-api/src/system/ApiProvider.tsx b/packages/frontend-plugin-api/src/system/ApiProvider.tsx new file mode 100644 index 0000000000..4894d25de0 --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiProvider.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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, { useContext, ReactNode, PropsWithChildren } from 'react'; +import { ApiHolder } from '@backstage/core-plugin-api'; +import { ApiAggregator } from './ApiAggregator'; +import { + createVersionedValueMap, + createVersionedContext, +} from '@backstage/version-bridge'; + +/** + * Prop types for the ApiProvider component. + * @public + */ +export type ApiProviderProps = { + apis: ApiHolder; + children: ReactNode; +}; + +const ApiContext = createVersionedContext<{ 1: ApiHolder }>('api-context'); + +/** + * Provides an {@link @backstage/core-plugin-api#ApiHolder} for consumption in + * the React tree. + * + * @public + */ +export const ApiProvider = (props: PropsWithChildren) => { + const { apis, children } = props; + const parentHolder = useContext(ApiContext)?.atVersion(1); + const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis; + + return ( + + ); +}; diff --git a/packages/frontend-plugin-api/src/system/ApiRegistry.test.ts b/packages/frontend-plugin-api/src/system/ApiRegistry.test.ts new file mode 100644 index 0000000000..c0be9f8d02 --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiRegistry.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 '@backstage/core-plugin-api'; +import { ApiRegistry } from './ApiRegistry'; + +describe('ApiRegistry', () => { + const x1Ref = createApiRef({ id: 'x1' }); + const x1DuplicateRef = createApiRef({ id: 'x1' }); + const x2Ref = createApiRef({ id: 'x2' }); + + it('should be created', () => { + const registry = ApiRegistry.from([]); + expect(registry.get(x1Ref)).toBe(undefined); + }); + + it('should be created with APIs', () => { + const registry = ApiRegistry.from([ + [x1Ref, 3], + [x2Ref, 'y'], + ]); + expect(registry.get(x1Ref)).toBe(3); + expect(registry.get(x1DuplicateRef)).toBe(3); + expect(registry.get(x2Ref)).toBe('y'); + }); + + it('should be built', () => { + const registry = ApiRegistry.builder().build(); + expect(registry.get(x1Ref)).toBe(undefined); + expect(registry.get(x1DuplicateRef)).toBe(undefined); + }); + + it('should be built with APIs', () => { + const builder = ApiRegistry.builder(); + builder.add(x1Ref, 3); + builder.add(x2Ref, 'y'); + + const registry = builder.build(); + expect(registry.get(x1Ref)).toBe(3); + expect(registry.get(x1DuplicateRef)).toBe(3); + expect(registry.get(x2Ref)).toBe('y'); + }); + + it('should be created with API', () => { + const reg1 = ApiRegistry.with(x1Ref, 3); + const reg2 = reg1.with(x2Ref, 'y'); + const reg3 = reg2.with(x2Ref, 'z'); + const reg4 = reg3.with(x1Ref, 2); + const reg5 = reg3.with(x1DuplicateRef, 4); + + expect(reg1.get(x1Ref)).toBe(3); + expect(reg1.get(x2Ref)).toBe(undefined); + expect(reg2.get(x1Ref)).toBe(3); + expect(reg2.get(x2Ref)).toBe('y'); + expect(reg3.get(x1Ref)).toBe(3); + expect(reg3.get(x2Ref)).toBe('z'); + expect(reg4.get(x1Ref)).toBe(2); + expect(reg4.get(x2Ref)).toBe('z'); + expect(reg5.get(x1Ref)).toBe(4); + expect(reg5.get(x2Ref)).toBe('z'); + }); +}); diff --git a/packages/frontend-plugin-api/src/system/ApiRegistry.ts b/packages/frontend-plugin-api/src/system/ApiRegistry.ts new file mode 100644 index 0000000000..d4990a04c5 --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiRegistry.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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, ApiHolder } from '@backstage/core-plugin-api'; + +type ApiImpl = readonly [ApiRef, T]; + +/** @internal */ +class ApiRegistryBuilder { + private apis: [string, unknown][] = []; + + add(api: ApiRef, impl: I): I { + this.apis.push([api.id, impl]); + return impl; + } + + build(): ApiRegistry { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return new ApiRegistry(new Map(this.apis)); + } +} + +/** + * A registry for utility APIs. + * + * @internal + */ +export class ApiRegistry implements ApiHolder { + static builder() { + return new ApiRegistryBuilder(); + } + + /** + * Creates a new ApiRegistry with a list of API implementations. + * + * @param apis - A list of pairs mapping an ApiRef to its respective implementation + */ + static from(apis: ApiImpl[]) { + return new ApiRegistry(new Map(apis.map(([api, impl]) => [api.id, impl]))); + } + + /** + * Creates a new ApiRegistry with a single API implementation. + * + * @param api - ApiRef for the API to add + * @param impl - Implementation of the API to add + */ + static with(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([[api.id, impl]])); + } + + constructor(private readonly apis: Map) {} + + /** + * Returns a new ApiRegistry with the provided API added to the existing ones. + * + * @param api - ApiRef for the API to add + * @param impl - Implementation of the API to add + */ + with(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([...this.apis, [api.id, impl]])); + } + + get(api: ApiRef): T | undefined { + return this.apis.get(api.id) as T | undefined; + } +} diff --git a/packages/frontend-plugin-api/src/system/ApiResolver.test.ts b/packages/frontend-plugin-api/src/system/ApiResolver.test.ts new file mode 100644 index 0000000000..01491684f4 --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiResolver.test.ts @@ -0,0 +1,263 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 '@backstage/core-plugin-api'; +import { ApiResolver } from './ApiResolver'; +import { ApiFactoryRegistry } from './ApiFactoryRegistry'; + +const aRef = createApiRef({ id: 'a' }); +const otherARef = createApiRef({ id: 'a' }); +const bRef = createApiRef({ id: 'b' }); +const otherBRef = createApiRef({ id: 'b' }); +const cRef = createApiRef<{ x: string }>({ id: 'c' }); +const otherCRef = createApiRef<{ x: string }>({ id: 'c' }); + +function createRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: {}, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: {}, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { b: otherBRef }, + factory: ({ b }) => ({ x: 'x', b }), + }); + return registry; +} + +function createSelfCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { a: aRef }, + factory: () => 1, + }); + return registry; +} + +function createShortCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: { a: aRef }, + factory: () => 'x', + }); + return registry; +} + +function createShortCyclicRegistryWithOther() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: otherBRef, + deps: { a: otherARef }, + factory: () => 'x', + }); + return registry; +} + +function createLongCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: otherBRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: { c: cRef }, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { a: aRef }, + factory: () => ({ x: 'x' }), + }); + return registry; +} + +describe('ApiResolver', () => { + it('should be created empty', () => { + const resolver = new ApiResolver(new ApiFactoryRegistry()); + expect(resolver.get(aRef)).toBe(undefined); + expect(resolver.get(bRef)).toBe(undefined); + expect(resolver.get(otherBRef)).toBe(undefined); + expect(resolver.get(cRef)).toBe(undefined); + }); + + it('should instantiate APIs', () => { + const resolver = new ApiResolver(createRegistry()); + expect(resolver.get(aRef)).toBe(1); + expect(resolver.get(otherARef)).toBe(1); + expect(resolver.get(bRef)).toBe('b'); + expect(resolver.get(otherBRef)).toBe('b'); + expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' }); + expect(resolver.get(cRef)).toBe(resolver.get(otherCRef)); + }); + + it('should detect self dependency cycles', () => { + const resolver = new ApiResolver(createSelfCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + }); + + it('should detect short dependency cycles', () => { + const resolver = new ApiResolver(createShortCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + }); + + it('should detect short dependency cycles with other refs', () => { + const resolver = new ApiResolver(createShortCyclicRegistryWithOther()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(otherARef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(otherBRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + }); + + it('should detect long dependency cycles', () => { + const resolver = new ApiResolver(createLongCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + // Second call for same ref should still throw + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(otherBRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(cRef)).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should validate a factory holder', () => { + expect(() => { + ApiResolver.validateFactories(createRegistry(), [ + aRef, + bRef, + otherBRef, + cRef, + ]); + }).not.toThrow(); + }); + + it('should find self cycles with validation', () => { + const self = createSelfCyclicRegistry(); + expect(() => ApiResolver.validateFactories(self, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(self, [otherARef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + }); + + it('should find dependency cycles with validation', () => { + const short = createShortCyclicRegistry(); + expect(() => ApiResolver.validateFactories(short, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(short, [otherARef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(short, [otherBRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + + const shortOther = createShortCyclicRegistryWithOther(); + expect(() => ApiResolver.validateFactories(shortOther, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => + ApiResolver.validateFactories(shortOther, [otherARef]), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(shortOther, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => + ApiResolver.validateFactories(shortOther, [otherBRef]), + ).toThrow('Circular dependency of api factory for apiRef{b}'); + + const long = createLongCyclicRegistry(); + expect(() => + ApiResolver.validateFactories(long, long.getAllApis()), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(long, [otherBRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should only call factory func once', () => { + const registry = new ApiFactoryRegistry(); + const factory = jest.fn().mockReturnValue(2); + registry.register('default', { + api: aRef, + deps: {}, + factory, + }); + + const resolver = new ApiResolver(registry); + expect(factory).toHaveBeenCalledTimes(0); + expect(resolver.get(aRef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + expect(resolver.get(aRef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + expect(resolver.get(otherARef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/frontend-plugin-api/src/system/ApiResolver.ts b/packages/frontend-plugin-api/src/system/ApiResolver.ts new file mode 100644 index 0000000000..0c050ebc30 --- /dev/null +++ b/packages/frontend-plugin-api/src/system/ApiResolver.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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, + ApiHolder, + AnyApiRef, + TypesToApiRefs, +} from '@backstage/core-plugin-api'; +import { ApiFactoryHolder } from './types'; + +/** + * Handles the actual on-demand instantiation and memoization of APIs out of + * an {@link ApiFactoryHolder}. + * + * @public + */ +export class ApiResolver implements ApiHolder { + /** + * Validate factories by making sure that each of the apis can be created + * without hitting any circular dependencies. + */ + static validateFactories( + factories: ApiFactoryHolder, + apis: Iterable, + ) { + for (const api of apis) { + const heap = [api]; + const allDeps = new Set(); + + while (heap.length) { + const apiRef = heap.shift()!; + const factory = factories.get(apiRef); + if (!factory) { + continue; + } + + for (const dep of Object.values(factory.deps)) { + if (dep.id === api.id) { + throw new Error(`Circular dependency of api factory for ${api}`); + } + if (!allDeps.has(dep)) { + allDeps.add(dep); + heap.push(dep); + } + } + } + } + } + + private readonly apis = new Map(); + + constructor(private readonly factories: ApiFactoryHolder) {} + + get(ref: ApiRef): T | undefined { + return this.load(ref); + } + + private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined { + const impl = this.apis.get(ref.id); + if (impl) { + return impl as T; + } + + const factory = this.factories.get(ref); + if (!factory) { + return undefined; + } + + if (loading.includes(factory.api)) { + throw new Error(`Circular dependency of api factory for ${factory.api}`); + } + + const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]); + const api = factory.factory(deps); + this.apis.set(ref.id, api); + return api as T; + } + + private loadDeps( + dependent: ApiRef, + apis: TypesToApiRefs, + loading: AnyApiRef[], + ): T { + const impls = {} as T; + + for (const key in apis) { + if (apis.hasOwnProperty(key)) { + const ref = apis[key]; + + const api = this.load(ref, loading); + if (!api) { + throw new Error( + `No API factory available for dependency ${ref} of dependent ${dependent}`, + ); + } + impls[key] = api; + } + } + + return impls; + } +} diff --git a/packages/frontend-plugin-api/src/system/index.ts b/packages/frontend-plugin-api/src/system/index.ts index 6aad24daa9..0650bff063 100644 --- a/packages/frontend-plugin-api/src/system/index.ts +++ b/packages/frontend-plugin-api/src/system/index.ts @@ -16,4 +16,7 @@ export { createApiRef } from './ApiRef'; export type { ApiRefConfig } from './ApiRef'; +export { ApiFactoryRegistry } from './ApiFactoryRegistry'; +export { ApiProvider } from './ApiProvider'; +export { ApiResolver } from './ApiResolver'; export * from './types'; diff --git a/packages/frontend-plugin-api/src/system/types.ts b/packages/frontend-plugin-api/src/system/types.ts index 96614c320c..730493c079 100644 --- a/packages/frontend-plugin-api/src/system/types.ts +++ b/packages/frontend-plugin-api/src/system/types.ts @@ -72,3 +72,12 @@ export type AnyApiFactory = ApiFactory< unknown, { [key in string]: unknown } >; + +/** + * @public + */ +export type ApiFactoryHolder = { + get( + api: ApiRef, + ): ApiFactory | undefined; +};