diff --git a/.changeset/rotten-dolls-sing.md b/.changeset/rotten-dolls-sing.md new file mode 100644 index 0000000000..039b287a77 --- /dev/null +++ b/.changeset/rotten-dolls-sing.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added new experimental `featureDiscoveryServiceRef`, available as an `/alpha` export. diff --git a/.changeset/serious-singers-vanish.md b/.changeset/serious-singers-vanish.md new file mode 100644 index 0000000000..e1f4a754a7 --- /dev/null +++ b/.changeset/serious-singers-vanish.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Added new experimental `featureDiscoveryServiceFactory`, available as an `/alpha` export. diff --git a/packages/backend-app-api/alpha-api-report.md b/packages/backend-app-api/alpha-api-report.md new file mode 100644 index 0000000000..488feedf2c --- /dev/null +++ b/packages/backend-app-api/alpha-api-report.md @@ -0,0 +1,16 @@ +## API Report File for "@backstage/backend-app-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { FeatureDiscoveryService } from '@backstage/backend-plugin-api/alpha'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @alpha (undocumented) +export const featureDiscoveryServiceFactory: () => ServiceFactory< + FeatureDiscoveryService, + 'root' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index d1aa511794..d1f3f8ac43 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -5,13 +5,26 @@ "main": "src/index.ts", "types": "src/index.ts", "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" + "access": "public" }, "backstage": { "role": "node-library" }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "homepage": "https://backstage.io", "repository": { "type": "git", @@ -36,6 +49,7 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/backend-tasks": "workspace:^", "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", "@backstage/config": "workspace:^", "@backstage/config-loader": "workspace:^", "@backstage/errors": "workspace:^", @@ -73,6 +87,7 @@ "@types/node-forge": "^1.3.0", "@types/stoppable": "^1.1.0", "http-errors": "^2.0.0", + "mock-fs": "^5.2.0", "supertest": "^6.1.3" }, "files": [ diff --git a/packages/backend-app-api/src/alpha.ts b/packages/backend-app-api/src/alpha.ts new file mode 100644 index 0000000000..a334214738 --- /dev/null +++ b/packages/backend-app-api/src/alpha.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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. + */ + +export { featureDiscoveryServiceFactory } from './alpha/featureDiscoveryServiceFactory'; diff --git a/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.test.ts b/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.test.ts new file mode 100644 index 0000000000..5a5917dd3b --- /dev/null +++ b/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2023 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 mockFs from 'mock-fs'; +import { resolve as resolvePath, dirname } from 'path'; +import { startTestBackend, mockServices } from '@backstage/backend-test-utils'; +import { featureDiscoveryServiceFactory } from './featureDiscoveryServiceFactory'; +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; + +const rootDir = dirname(process.argv[1]); + +describe('featureDiscoveryServiceFactory', () => { + beforeEach(() => { + mockFs({ + [rootDir]: { + 'package.json': JSON.stringify({ + name: 'example-app', + dependencies: { + 'detected-plugin': '0.0.0', + 'detected-module': '0.0.0', + }, + }), + }, + [resolvePath(rootDir, 'node_modules/detected-plugin')]: { + 'package.json': JSON.stringify({ + name: 'detected-plugin', + main: 'index.js', + backstage: { + role: 'backend-plugin', + }, + }), + 'index.js': ` + const { createBackendPlugin, coreServices } = require('@backstage/backend-plugin-api'); + exports.detectedPlugin = createBackendPlugin({ + pluginId: 'detected', + register(env) { + env.registerInit({ + deps: { identity: coreServices.identity }, + async init({ identity }) { + identity.getIdentity('detected-plugin'); + }, + }); + }, + }); + `, + }, + [resolvePath(rootDir, 'node_modules/detected-module')]: { + 'package.json': JSON.stringify({ + name: 'detected-module', + main: 'index.js', + backstage: { + role: 'backend-module', + }, + }), + 'index.js': ` + const { createBackendModule, coreServices } = require('@backstage/backend-plugin-api'); + exports.detectedModuleDerp = createBackendModule({ + pluginId: 'detected', + moduleId: 'derp', + register(env) { + env.registerInit({ + deps: { identity: coreServices.identity }, + async init({ identity }) { + identity.getIdentity('detected-module'); + }, + }); + }, + }); + `, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should detect plugin and module packages', async () => { + const fn = jest.fn().mockResolvedValue({}); + + await startTestBackend({ + services: [ + createServiceFactory({ + service: coreServices.identity, + deps: {}, + factory: () => ({ getIdentity: fn }), + }), + featureDiscoveryServiceFactory(), + mockServices.rootConfig.factory({ + data: { backend: { packages: 'all' } }, + }), + ], + }); + + expect(fn).toHaveBeenCalledWith('detected-plugin'); + expect(fn).toHaveBeenCalledWith('detected-module'); + }); +}); diff --git a/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.ts b/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.ts new file mode 100644 index 0000000000..f9ee7edc5f --- /dev/null +++ b/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2023 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 { + BackendFeature, + RootConfigService, + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { + featureDiscoveryServiceRef, + FeatureDiscoveryService, +} from '@backstage/backend-plugin-api/alpha'; +import { resolve as resolvePath, dirname } from 'path'; +import fs from 'fs-extra'; +import { BackstagePackageJson } from '@backstage/cli-node'; + +const LOADED_PACKAGE_ROLES = ['backend-plugin', 'backend-module']; + +/** @internal */ +async function findClosestPackageDir( + searchDir: string, +): Promise { + let path = searchDir; + + // Some confidence check to avoid infinite loop + for (let i = 0; i < 1000; i++) { + const packagePath = resolvePath(path, 'package.json'); + const exists = await fs.pathExists(packagePath); + if (exists) { + return path; + } + + const newPath = dirname(path); + if (newPath === path) { + return undefined; + } + path = newPath; + } + + throw new Error( + `Iteration limit reached when searching for root package.json at ${searchDir}`, + ); +} + +/** @internal */ +class PackageDiscoveryService implements FeatureDiscoveryService { + constructor(private readonly config: RootConfigService) {} + + async getBackendFeatures(): Promise<{ features: Array }> { + if (this.config.getOptionalString('backend.packages') !== 'all') { + return { features: [] }; + } + + const packageDir = await findClosestPackageDir(process.argv[1]); + if (!packageDir) { + throw new Error('Package discovery failed to find package.json'); + } + const { dependencies } = require(resolvePath( + packageDir, + 'package.json', + )) as BackstagePackageJson; + const dependencyNames = Object.keys(dependencies || {}); + + const features: BackendFeature[] = []; + + for (const name of dependencyNames) { + const depPkg = require(require.resolve(`${name}/package.json`, { + paths: [packageDir], + })) as BackstagePackageJson; + if (!LOADED_PACKAGE_ROLES.includes(depPkg?.backstage?.role ?? '')) { + continue; + } + const depModule = require(require.resolve(name, { paths: [packageDir] })); + for (const exportValue of Object.values(depModule)) { + if (isBackendFeature(exportValue)) { + features.push(exportValue); + } + if (isBackendFeatureFactory(exportValue)) { + features.push(exportValue()); + } + } + } + + return { features }; + } +} + +/** @alpha */ +export const featureDiscoveryServiceFactory = createServiceFactory({ + service: featureDiscoveryServiceRef, + deps: { + config: coreServices.rootConfig, + }, + factory({ config }) { + return new PackageDiscoveryService(config); + }, +}); + +function isBackendFeature(value: unknown): value is BackendFeature { + return ( + !!value && + typeof value === 'object' && + (value as BackendFeature).$$type === '@backstage/BackendFeature' + ); +} + +function isBackendFeatureFactory( + value: unknown, +): value is () => BackendFeature { + return ( + !!value && + typeof value === 'function' && + (value as any).$$type === '@backstage/BackendFeatureFactory' + ); +} diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index ec4b2a79ac..6bc4e995e2 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -27,6 +27,7 @@ import { EnumerableServiceHolder, ServiceOrExtensionPoint } from './types'; // eslint-disable-next-line @backstage/no-forbidden-package-imports import { InternalBackendFeature } from '@backstage/backend-plugin-api/src/wiring/types'; import { ForwardedError } from '@backstage/errors'; +import { featureDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; export interface BackendRegisterInit { consumes: Set; @@ -87,6 +88,10 @@ export class BackendInitializer { if (this.#startPromise) { throw new Error('feature can not be added after the backend has started'); } + this.#addFeature(feature); + } + + #addFeature(feature: BackendFeature) { if (feature.$$type !== '@backstage/BackendFeature') { throw new Error( `Failed to add feature, invalid type '${feature.$$type}'`, @@ -129,6 +134,18 @@ export class BackendInitializer { } async #doStart(): Promise { + const featureDiscovery = await this.#serviceHolder.get( + featureDiscoveryServiceRef, + 'root', + ); + + if (featureDiscovery) { + const { features } = await featureDiscovery.getBackendFeatures(); + for (const feature of features) { + this.#addFeature(feature); + } + } + // Initialize all root scoped services for (const ref of this.#serviceHolder.getServiceRefs()) { if (ref.scope === 'root') { diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index af4f483db0..f36f90998b 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/backend-tasks": "workspace:^", "@backstage/plugin-adr-backend": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", diff --git a/packages/backend-plugin-api/alpha-api-report.md b/packages/backend-plugin-api/alpha-api-report.md new file mode 100644 index 0000000000..81b378a671 --- /dev/null +++ b/packages/backend-plugin-api/alpha-api-report.md @@ -0,0 +1,24 @@ +## API Report File for "@backstage/backend-plugin-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { ServiceRef } from '@backstage/backend-plugin-api'; + +// @alpha (undocumented) +export interface FeatureDiscoveryService { + // (undocumented) + getBackendFeatures(): Promise<{ + features: Array; + }>; +} + +// @alpha +export const featureDiscoveryServiceRef: ServiceRef< + FeatureDiscoveryService, + 'root' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 5d0bfe08fb..555c60c8e4 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -5,13 +5,26 @@ "main": "src/index.ts", "types": "src/index.ts", "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" + "access": "public" }, "backstage": { "role": "node-library" }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "homepage": "https://backstage.io", "repository": { "type": "git", diff --git a/packages/backend-plugin-api/src/alpha.ts b/packages/backend-plugin-api/src/alpha.ts new file mode 100644 index 0000000000..baee739f49 --- /dev/null +++ b/packages/backend-plugin-api/src/alpha.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2023 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 { + BackendFeature, + createServiceRef, +} from '@backstage/backend-plugin-api'; + +/** @alpha */ +export interface FeatureDiscoveryService { + getBackendFeatures(): Promise<{ features: Array }>; +} + +/** + * An optional service that can be used to dynamically load in additional BackendFeatures at runtime. + * @alpha + */ +export const featureDiscoveryServiceRef = + createServiceRef({ + id: 'core.featureDiscovery', + scope: 'root', + }); diff --git a/packages/backend-plugin-api/src/wiring/factories.ts b/packages/backend-plugin-api/src/wiring/factories.ts index 928c933af0..ecaccdc6de 100644 --- a/packages/backend-plugin-api/src/wiring/factories.ts +++ b/packages/backend-plugin-api/src/wiring/factories.ts @@ -17,11 +17,11 @@ import { BackendModuleRegistrationPoints, BackendPluginRegistrationPoints, - BackendFeature, ExtensionPoint, - InternalBackendFeature, InternalBackendModuleRegistration, InternalBackendPluginRegistration, + BackendFeatureFactory, + BackendFeature, } from './types'; /** @@ -89,7 +89,8 @@ export function createBackendPlugin( config: BackendPluginConfig | ((...params: TOptions) => BackendPluginConfig), ): (...params: TOptions) => BackendFeature { const configCallback = typeof config === 'function' ? config : () => config; - return (...options: TOptions): InternalBackendFeature => { + + const factory: BackendFeatureFactory = (...options) => { const c = configCallback(...options); let registrations: InternalBackendPluginRegistration[]; @@ -144,6 +145,9 @@ export function createBackendPlugin( }, }; }; + factory.$$type = '@backstage/BackendFeatureFactory'; + + return factory; } /** @@ -178,7 +182,7 @@ export function createBackendModule( config: BackendModuleConfig | ((...params: TOptions) => BackendModuleConfig), ): (...params: TOptions) => BackendFeature { const configCallback = typeof config === 'function' ? config : () => config; - return (...options: TOptions): InternalBackendFeature => { + const factory: BackendFeatureFactory = (...options: TOptions) => { const c = configCallback(...options); let registrations: InternalBackendModuleRegistration[]; @@ -223,4 +227,7 @@ export function createBackendModule( }, }; }; + factory.$$type = '@backstage/BackendFeatureFactory'; + + return factory; } diff --git a/packages/backend-plugin-api/src/wiring/types.ts b/packages/backend-plugin-api/src/wiring/types.ts index c81ee3ceb7..028c2b87da 100644 --- a/packages/backend-plugin-api/src/wiring/types.ts +++ b/packages/backend-plugin-api/src/wiring/types.ts @@ -67,6 +67,14 @@ export interface BackendModuleRegistrationPoints { }): void; } +/** @internal */ +export interface BackendFeatureFactory< + TOptions extends [options?: object] = [], +> { + (...options: TOptions): BackendFeature; + $$type: '@backstage/BackendFeatureFactory'; +} + /** @public */ export interface BackendFeature { // NOTE: This type is opaque in order to simplify future API evolution. diff --git a/yarn.lock b/yarn.lock index 4d87a85578..84a1db2846 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3292,6 +3292,7 @@ __metadata: "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/cli-common": "workspace:^" + "@backstage/cli-node": "workspace:^" "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^" "@backstage/errors": "workspace:^" @@ -3319,6 +3320,7 @@ __metadata: logform: ^2.3.2 minimatch: ^5.0.0 minimist: ^1.2.5 + mock-fs: ^5.2.0 morgan: ^1.10.0 node-forge: ^1.3.1 selfsigned: ^2.0.0 @@ -25161,6 +25163,7 @@ __metadata: resolution: "example-backend-next@workspace:packages/backend-next" dependencies: "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-tasks": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/plugin-adr-backend": "workspace:^"