Merge pull request #13347 from backstage/jhaals/catalogService

catalog-node: Export experimental catalogServiceRef
This commit is contained in:
Johan Haals
2022-08-31 15:35:16 +02:00
committed by GitHub
13 changed files with 362 additions and 24 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-node': patch
---
Adds experimental `catalogServiceRef` for obtaining a `CatalogClient` in the new backend system.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Updated `ServiceRegistry` to not initialize factories more than once.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Added alpha `scaffolderPlugin` to be used with experimental backend system.
@@ -17,6 +17,7 @@
import {
createServiceRef,
createServiceFactory,
ServiceRef,
} from '@backstage/backend-plugin-api';
import { ServiceRegistry } from './ServiceRegistry';
@@ -170,4 +171,53 @@ describe('ServiceRegistry', () => {
expect(await factoryB('catalog')).toBe(await factoryB('catalog'));
expect(await factoryA('catalog')).not.toBe(await factoryB('catalog'));
});
it('should only call each default factory loader once', async () => {
const factoryLoader = jest.fn(async (service: ServiceRef<void>) =>
createServiceFactory({
service,
deps: {},
factory: async () => async () => {},
}),
);
const ref = createServiceRef<void>({
id: '1',
defaultFactory: factoryLoader,
});
const registry = new ServiceRegistry([]);
const factory = registry.get(ref)!;
await Promise.all([
expect(factory('catalog')).resolves.toBeUndefined(),
expect(factory('catalog')).resolves.toBeUndefined(),
]);
expect(factoryLoader).toHaveBeenCalledTimes(1);
});
it('should not call factory functions more than once', async () => {
const innerFactory = jest.fn(async (pluginId: string) => {
return { x: 1, pluginId };
});
const factory = jest.fn(async () => innerFactory);
const myFactory = createServiceFactory({
service: ref1,
deps: {},
factory,
});
const registry = new ServiceRegistry([myFactory]);
await Promise.all([
registry.get(ref1)!('catalog')!,
registry.get(ref1)!('catalog')!,
registry.get(ref1)!('catalog')!,
registry.get(ref1)!('scaffolder')!,
registry.get(ref1)!('scaffolder')!,
]);
expect(factory).toHaveBeenCalledTimes(1);
expect(innerFactory).toHaveBeenCalledTimes(2);
expect(innerFactory).toHaveBeenCalledWith('catalog');
expect(innerFactory).toHaveBeenCalledWith('scaffolder');
});
});
@@ -21,8 +21,14 @@ import {
export class ServiceRegistry {
readonly #providedFactories: Map<string, ServiceFactory>;
readonly #loadedDefaultFactories: Map<Function, ServiceFactory>;
readonly #implementations: Map<ServiceFactory, Map<string, unknown>>;
readonly #loadedDefaultFactories: Map<Function, Promise<ServiceFactory>>;
readonly #implementations: Map<
ServiceFactory,
{
factoryFunc: Promise<FactoryFunc<unknown>>;
byPlugin: Map<string, Promise<unknown>>;
}
>;
constructor(factories: ServiceFactory<any>[]) {
this.#providedFactories = new Map(factories.map(f => [f.service.id, f]));
@@ -41,35 +47,38 @@ export class ServiceRegistry {
if (!factory) {
let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory!);
if (!loadedFactory) {
loadedFactory = (await defaultFactory!(ref)) as ServiceFactory;
loadedFactory = defaultFactory!(ref) as Promise<ServiceFactory>;
this.#loadedDefaultFactories.set(defaultFactory!, loadedFactory);
}
factory = loadedFactory;
// NOTE: This await is safe as long as #providedFactories is not mutated.
factory = await loadedFactory;
}
let implementations = this.#implementations.get(factory);
if (implementations) {
if (implementations.has(pluginId)) {
return implementations.get(pluginId) as T;
}
} else {
implementations = new Map();
this.#implementations.set(factory, implementations);
let implementation = this.#implementations.get(factory);
if (!implementation) {
const factoryDeps = Object.fromEntries(
Object.entries(factory.deps).map(([name, serviceRef]) => [
name,
this.get(serviceRef)!, // TODO: throw
]),
);
implementation = {
factoryFunc: factory.factory(factoryDeps),
byPlugin: new Map(),
};
this.#implementations.set(factory, implementation);
}
const factoryDeps = Object.fromEntries(
Object.entries(factory.deps).map(([name, serviceRef]) => [
name,
this.get(serviceRef)!, // TODO: throw
]),
);
let result = implementation.byPlugin.get(pluginId) as Promise<any>;
if (!result) {
result = implementation.factoryFunc.then(func => func(pluginId));
const factoryFunc = await factory.factory(factoryDeps);
const implementation = await factoryFunc(pluginId);
implementation.byPlugin.set(pluginId, result);
}
implementations.set(pluginId, implementation);
return implementation as T;
return result;
};
}
}
+5
View File
@@ -5,10 +5,12 @@
```ts
/// <reference types="node" />
import { CatalogApi } from '@backstage/catalog-client';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
import { JsonValue } from '@backstage/types';
import { ServiceRef } from '@backstage/backend-plugin-api';
// @alpha (undocumented)
export interface CatalogProcessingExtensionPoint {
@@ -106,6 +108,9 @@ export type CatalogProcessorResult =
| CatalogProcessorErrorResult
| CatalogProcessorRefreshKeysResult;
// @alpha
export const catalogServiceRef: ServiceRef<CatalogApi>;
// @public
export type DeferredEntity = {
entity: Entity;
+3 -1
View File
@@ -26,13 +26,15 @@
},
"dependencies": {
"@backstage/backend-plugin-api": "^0.1.2-next.0",
"@backstage/catalog-client": "^1.0.5-next.0",
"@backstage/catalog-model": "^1.1.0",
"@backstage/errors": "1.1.0",
"@backstage/types": "^1.0.0"
},
"devDependencies": {
"@backstage/backend-common": "^0.15.1-next.1",
"@backstage/cli": "^0.19.0-next.1"
"@backstage/cli": "^0.19.0-next.1",
"@backstage/backend-test-utils": "^0.1.28-next.1"
},
"files": [
"dist",
@@ -0,0 +1,59 @@
/*
* Copyright 2022 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 { PluginEndpointDiscovery } from '@backstage/backend-common';
import {
createBackendModule,
createServiceFactory,
discoveryServiceRef,
} from '@backstage/backend-plugin-api';
import { startTestBackend } from '@backstage/backend-test-utils';
import { CatalogClient } from '@backstage/catalog-client';
import { catalogServiceRef } from './catalogService';
describe('catalogServiceRef', () => {
it('should return a catalogClient', async () => {
expect.assertions(1);
const mockDiscoveryFactory = createServiceFactory({
service: discoveryServiceRef,
deps: {},
factory: async ({}) => {
return async () => jest.fn() as unknown as PluginEndpointDiscovery;
},
});
const testModule = createBackendModule({
moduleId: 'test.module',
pluginId: 'test',
register(env) {
env.registerInit({
deps: {
catalog: catalogServiceRef,
},
async init({ catalog }) {
expect(catalog).toBeInstanceOf(CatalogClient);
},
});
},
});
await startTestBackend({
services: [mockDiscoveryFactory],
features: [testModule({})],
});
});
});
@@ -0,0 +1,44 @@
/*
* Copyright 2022 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 {
createServiceFactory,
createServiceRef,
discoveryServiceRef,
} from '@backstage/backend-plugin-api';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
/**
* The catalogService provides the catalog API.
* @alpha
*/
export const catalogServiceRef = createServiceRef<CatalogApi>({
id: 'catalog-client',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {
discoveryFactory: discoveryServiceRef,
},
factory: async ({ discoveryFactory }) => {
const discoveryApi = await discoveryFactory('root');
const catalogClient = new CatalogClient({ discoveryApi });
return async _pluginId => {
return catalogClient;
};
},
}),
});
+1
View File
@@ -22,5 +22,6 @@
export type { CatalogProcessingExtensionPoint } from './extensions';
export { catalogProcessingExtensionPoint } from './extensions';
export { catalogServiceRef } from './catalogService';
export * from './api';
export * from './processing';
+13
View File
@@ -569,6 +569,19 @@ export class ScaffolderEntitiesProcessor implements CatalogProcessor {
validateEntityKind(entity: Entity): Promise<boolean>;
}
// @alpha
export const scaffolderPlugin: (
options: ScaffolderPluginOptions,
) => BackendFeature;
// @alpha
export type ScaffolderPluginOptions = {
actions?: TemplateAction<any>[];
taskWorkers?: number;
taskBroker?: TaskBroker;
additionalTemplateFilters?: Record<string, TemplateFilter>;
};
// @public
export type SerializedTask = {
id: string;
@@ -0,0 +1,138 @@
/*
* Copyright 2022 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 {
configServiceRef,
createBackendPlugin,
databaseServiceRef,
loggerServiceRef,
loggerToWinstonLogger,
permissionsServiceRef,
urlReaderServiceRef,
httpRouterServiceRef,
createExtensionPoint,
} from '@backstage/backend-plugin-api';
import { ScmIntegrations } from '@backstage/integration';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { TemplateFilter } from './lib';
import { createBuiltinActions, TaskBroker, TemplateAction } from './scaffolder';
import { createRouter } from './service/router';
/**
* Catalog plugin options
* @alpha
*/
export type ScaffolderPluginOptions = {
actions?: TemplateAction<any>[];
taskWorkers?: number;
taskBroker?: TaskBroker;
additionalTemplateFilters?: Record<string, TemplateFilter>;
};
/**
* @alpha
* TODO: MOVE to scaffolder-node.
*/
interface ScaffolderActionsExtensionPoint {
addActions(...actions: TemplateAction<any>[]): void;
}
class ScaffolderActionsExtensionPointImpl
implements ScaffolderActionsExtensionPoint
{
#actions = new Array<TemplateAction<any>>();
addActions(...actions: TemplateAction<any>[]): void {
this.#actions.push(...actions);
}
get actions() {
return this.#actions;
}
}
/**
* @alpha
* TODO: MOVE to scaffolder-node.
*/
export const scaffolderActionsExtensionPoint =
createExtensionPoint<ScaffolderActionsExtensionPoint>({
id: 'scaffolder.actions',
});
/**
* Catalog plugin
* @alpha
*/
export const scaffolderPlugin = createBackendPlugin({
id: 'scaffolder',
register(env, options: ScaffolderPluginOptions) {
const actionsExtensions = new ScaffolderActionsExtensionPointImpl();
env.registerExtensionPoint(
scaffolderActionsExtensionPoint,
actionsExtensions,
);
env.registerInit({
deps: {
logger: loggerServiceRef,
config: configServiceRef,
reader: urlReaderServiceRef,
permissions: permissionsServiceRef,
database: databaseServiceRef,
httpRouter: httpRouterServiceRef,
catalogClient: catalogServiceRef,
},
async init({
logger,
config,
reader,
database,
httpRouter,
catalogClient,
}) {
const { additionalTemplateFilters, taskBroker, taskWorkers } = options;
const log = loggerToWinstonLogger(logger);
const actions = options.actions || [
...actionsExtensions.actions,
...createBuiltinActions({
integrations: ScmIntegrations.fromConfig(config),
catalogClient,
reader,
config,
additionalTemplateFilters,
}),
];
const actionIds = actions.map(action => action.id).join(', ');
log.info(
`Starting scaffolder with the following actions enabled ${actionIds}`,
);
const router = await createRouter({
logger: log,
config,
database,
catalogClient,
reader,
actions,
taskBroker,
taskWorkers,
additionalTemplateFilters,
});
httpRouter.use(router);
},
});
},
});
+2
View File
@@ -25,3 +25,5 @@ export * from './service/router';
export * from './lib';
export * from './processor';
export * from './extension';
export { scaffolderPlugin } from './ScaffolderPlugin';
export type { ScaffolderPluginOptions } from './ScaffolderPlugin';