Copy over missing APIs

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Signed-off-by: Philipp Hugenroth <philipph@spotify.com>
This commit is contained in:
Philipp Hugenroth
2023-11-06 15:55:31 +01:00
committed by Patrik Oldsberg
parent 05a0506afa
commit a70e693707
13 changed files with 1150 additions and 48 deletions
@@ -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,
@@ -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<number>({ id: 'a' });
const apiBRef = createApiRef<number>({ 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);
});
});
@@ -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<T>(apiRef: ApiRef<T>): T | undefined {
for (const holder of this.holders) {
const api = holder.get(apiRef);
if (api) {
return api;
}
}
return undefined;
}
}
@@ -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<number>({ id: 'a' });
const aFactory1 = { api: aRef, deps: {}, factory: () => 1 };
const aFactory2 = { api: aRef, deps: {}, factory: () => 2 };
const bRef = createApiRef<string>({ id: 'b' });
const bFactory = { api: bRef, deps: {}, factory: () => 'x' };
const cRef = createApiRef<string>({ 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<number>({ id: 'a' });
const ref2 = createApiRef<number>({ 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);
});
});
@@ -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<string, FactoryTuple>();
/**
* 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<Api, Impl extends Api, Deps extends { [name in string]: unknown }>(
scope: ApiFactoryScope,
factory: ApiFactory<Api, Impl, Deps>,
) {
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<T>(
api: ApiRef<T>,
): ApiFactory<T, T, { [x: string]: unknown }> | undefined {
const tuple = this.factories.get(api.id);
if (!tuple) {
return undefined;
}
return tuple.factory as ApiFactory<T, T, { [x: string]: unknown }>;
}
getAllApis(): Set<AnyApiRef> {
const refs = new Set<AnyApiRef>();
for (const { factory } of this.factories.values()) {
refs.add(factory.api);
}
return refs;
}
}
@@ -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<Api>({ id: 'x' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumer = () => {
const api = useApi(apiRef);
return <p>hook message: {api()}</p>;
};
const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => {
return <p>hoc message: {getMessage()}</p>;
});
it('should provide apis', () => {
const renderedHook = render(
<ApiProvider apis={registry}>
<MyHookConsumer />
</ApiProvider>,
);
renderedHook.getByText('hook message: hello');
const renderedHoc = render(
<ApiProvider apis={registry}>
<MyHocConsumer />
</ApiProvider>,
);
renderedHoc.getByText('hoc message: hello');
});
it('should provide nested access to apis', () => {
const aRef = createApiRef<string>({ id: 'a' });
const bRef = createApiRef<string>({ id: 'b' });
const MyComponent = () => {
const a = useApi(aRef);
const b = useApi(bRef);
return (
<div>
a={a} b={b}
</div>
);
};
const renderedHook = render(
<ApiProvider
apis={ApiRegistry.from([
[aRef, 'x'],
[bRef, 'y'],
])}
>
<ApiProvider apis={ApiRegistry.from([[aRef, 'z']])}>
<MyComponent />
</ApiProvider>
</ApiProvider>,
);
renderedHook.getByText('a=z b=y');
});
it('should ignore deps in prototype', () => {
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
const xRef = createApiRef<number>({ 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 <p>hoc message: {getMessage()}</p>;
});
const renderedHoc = render(
<ApiProvider apis={registry}>
<MyWeirdHocConsumer />
</ApiProvider>,
);
renderedHoc.getByText('hoc message: hello');
});
it('should error if no provider is available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(<MyHookConsumer />);
}).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 <MyHookConsumer> component/,
),
]);
expect(
withLogCollector(['error'], () => {
expect(() => {
render(<MyHocConsumer />);
}).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 <withApis\(Component\)> component/,
),
]);
});
it('should error if api is not available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<ApiProvider apis={ApiRegistry.from([])}>
<MyHookConsumer />
</ApiProvider>,
);
}).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 <MyHookConsumer> component/,
),
]);
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<ApiProvider apis={ApiRegistry.from([])}>
<MyHocConsumer />
</ApiProvider>,
);
}).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 <withApis\(Component\)> component/,
),
]);
});
});
describe('v1 consumer', () => {
function useMockApiV1<T>(apiRef: ApiRef<T>): 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<Api>({ id: 'x' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumerV1 = () => {
const api = useMockApiV1(apiRef);
return <p>hook message: {api()}</p>;
};
it('should provide apis', () => {
const renderedHook = render(
<ApiProvider apis={registry}>
<MyHookConsumerV1 />
</ApiProvider>,
);
renderedHook.getByText('hook message: hello');
});
});
@@ -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<ApiProviderProps>) => {
const { apis, children } = props;
const parentHolder = useContext(ApiContext)?.atVersion(1);
const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis;
return (
<ApiContext.Provider
value={createVersionedValueMap({ 1: holder })}
children={children}
/>
);
};
@@ -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<number>({ id: 'x1' });
const x1DuplicateRef = createApiRef<number>({ id: 'x1' });
const x2Ref = createApiRef<string>({ 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');
});
});
@@ -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<T = unknown> = readonly [ApiRef<T>, T];
/** @internal */
class ApiRegistryBuilder {
private apis: [string, unknown][] = [];
add<T, I extends T>(api: ApiRef<T>, 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<T>(api: ApiRef<T>, impl: T): ApiRegistry {
return new ApiRegistry(new Map([[api.id, impl]]));
}
constructor(private readonly apis: Map<string, unknown>) {}
/**
* 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<T>(api: ApiRef<T>, impl: T): ApiRegistry {
return new ApiRegistry(new Map([...this.apis, [api.id, impl]]));
}
get<T>(api: ApiRef<T>): T | undefined {
return this.apis.get(api.id) as T | undefined;
}
}
@@ -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<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();
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);
});
});
@@ -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<AnyApiRef>,
) {
for (const api of apis) {
const heap = [api];
const allDeps = new Set<AnyApiRef>();
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<string, unknown>();
constructor(private readonly factories: ApiFactoryHolder) {}
get<T>(ref: ApiRef<T>): T | undefined {
return this.load(ref);
}
private load<T>(ref: ApiRef<T>, 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<T>(
dependent: ApiRef<unknown>,
apis: TypesToApiRefs<T>,
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;
}
}
@@ -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';
@@ -72,3 +72,12 @@ export type AnyApiFactory = ApiFactory<
unknown,
{ [key in string]: unknown }
>;
/**
* @public
*/
export type ApiFactoryHolder = {
get<T>(
api: ApiRef<T>,
): ApiFactory<T, T, { [key in string]: unknown }> | undefined;
};