Merge pull request #20646 from backstage/mob/interop

add core-compat-api package with initial helper for top-down migration
This commit is contained in:
Patrik Oldsberg
2023-10-18 13:41:08 +02:00
committed by GitHub
15 changed files with 477 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Installed features are now deduplicated both by reference and ID when available. Features passed to `createApp` now override both discovered and loaded features.
+1
View File
@@ -12,6 +12,7 @@
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/core-compat-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
+36 -1
View File
@@ -27,6 +27,7 @@ import homePlugin, {
import {
coreExtensionData,
createExtension,
createApiExtension,
createExtensionOverrides,
createPageExtension,
} from '@backstage/frontend-plugin-api';
@@ -34,6 +35,16 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { homePage } from './HomePage';
import { collectLegacyRoutes } from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
import { Route } from 'react-router';
import { CatalogImportPage } from '@backstage/plugin-catalog-import';
import { createApiFactory, configApiRef } from '@backstage/core-plugin-api';
import {
ScmAuth,
ScmIntegrationsApi,
scmIntegrationsApiRef,
} from '@backstage/integration-react';
/*
@@ -83,6 +94,24 @@ const homePageExtension = createExtension({
},
});
const scmAuthExtension = createApiExtension({
factory: ScmAuth.createDefaultApiFactory(),
});
const scmIntegrationApi = createApiExtension({
factory: createApiFactory({
api: scmIntegrationsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
}),
});
const collectedLegacyPlugins = collectLegacyRoutes(
<FlatRoutes>
<Route path="/catalog-import" element={<CatalogImportPage />} />
</FlatRoutes>,
);
const app = createApp({
features: [
graphiqlPlugin,
@@ -91,8 +120,14 @@ const app = createApp({
techdocsPlugin,
userSettingsPlugin,
homePlugin,
...collectedLegacyPlugins,
createExtensionOverrides({
extensions: [entityPageExtension, homePageExtension],
extensions: [
entityPageExtension,
homePageExtension,
scmAuthExtension,
scmIntegrationApi,
],
}),
],
/* Handled through config instead */
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+12
View File
@@ -0,0 +1,12 @@
# @backstage/core-compat-api
_This package was created through the Backstage CLI_.
## Installation
Install the package via Yarn:
```sh
cd <package-dir> # if within a monorepo
yarn add @backstage/core-compat-api
```
+16
View File
@@ -0,0 +1,16 @@
## API Report File for "@backstage/core-compat-api"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
// @public (undocumented)
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[];
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-core-compat-api
title: '@backstage/core-compat-api'
spec:
lifecycle: experimental
type: backstage-web-library
owner: maintainers
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@backstage/core-compat-api",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "web-library"
},
"sideEffects": false,
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/plugin-puppetdb": "workspace:^",
"@backstage/plugin-stackstorm": "workspace:^",
"@oriflame/backstage-plugin-score-card": "^0.7.0",
"@testing-library/jest-dom": "^6.0.0"
},
"files": [
"dist"
],
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^"
}
}
@@ -0,0 +1,97 @@
/*
* 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 { FlatRoutes } from '@backstage/core-app-api';
import { PuppetDbPage } from '@backstage/plugin-puppetdb';
import { StackstormPage } from '@backstage/plugin-stackstorm';
import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card';
import React from 'react';
import { Route } from 'react-router-dom';
import { collectLegacyRoutes } from './collectLegacyRoutes';
describe('collectLegacyRoutes', () => {
it('should collect legacy routes', () => {
const collected = collectLegacyRoutes(
<FlatRoutes>
<Route path="/score-board" element={<ScoreBoardPage />} />
<Route path="/stackstorm" element={<StackstormPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
</FlatRoutes>,
);
expect(
collected.map(p => ({
id: p.id,
extensions: p.extensions.map(e => ({
id: e.id,
attachTo: e.attachTo,
disabled: e.disabled,
defaultConfig: e.configSchema?.parse({}),
})),
})),
).toEqual([
{
id: 'score-card',
extensions: [
{
id: 'plugin.score-card.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'score-board' },
},
{
id: 'apis.plugin.scoringdata.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
{
id: 'stackstorm',
extensions: [
{
id: 'plugin.stackstorm.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'stackstorm' },
},
{
id: 'apis.plugin.stackstorm.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
{
id: 'puppetDb',
extensions: [
{
id: 'plugin.puppetDb.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'apis.plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
]);
});
});
@@ -0,0 +1,137 @@
/*
* 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 React, { ReactNode } from 'react';
import {
Extension,
createApiExtension,
createPageExtension,
createPlugin,
BackstagePlugin,
} from '@backstage/frontend-plugin-api';
import { Route, Routes } from 'react-router-dom';
import {
BackstagePlugin as LegacyBackstagePlugin,
RouteRef,
getComponentData,
} from '@backstage/core-plugin-api';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
/*
# Legacy interoperability
Use-cases (prioritized):
1. Slowly migrate over an existing app to DI, piece by piece
2. Use a legacy plugin in a new DI app
3. Use DI in an existing legacy app
Starting point: use-case #1
Potential solutions:
1. Codemods (we're not considering this for now)
2. Legacy apps are migrated bottom-up, i.e. keep legacy root, replace pages with DI
3. Legacy apps are migrated top-down i.e. switch out base to DI, legacy adapter allows for usage of existing app structure
Chosen path: #3
Existing tasks:
- Adopters can migrate their existing app gradually (~4)
- Example-app uses legacy base with DI adapters
- Create an API that lets you inject DI into existing apps - working assumption is that this is enough
- Adopters can use legacy plugins in DI through adapters (~8)
- App-next uses DI base with legacy adapters
- Create a legacy adapter that is able to take an existing extension tree
*/
/** @public */
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[] {
const results = new Array<BackstagePlugin>();
React.Children.forEach(
flatRoutesElement.props.children,
(route: ReactNode) => {
if (!React.isValidElement(route)) {
return;
}
// TODO(freben): Handle feature flag and permissions framework wrapper elements
if (route.type !== Route) {
return;
}
const routeElement = route.props.element;
// TODO: to support deeper extension component, e.g. hidden within <RequirePermission>, use https://github.com/backstage/backstage/blob/518a34646b79ec2028cc0ed6bc67d4366c51c4d6/packages/core-app-api/src/routing/collectors.tsx#L69
const plugin = getComponentData<LegacyBackstagePlugin>(
routeElement,
'core.plugin',
);
if (!plugin) {
return;
}
const routeRef = getComponentData<RouteRef>(
routeElement,
'core.mountPoint',
);
const pluginId = plugin.getId();
const path: string = route.props.path;
const detectedExtensions = new Array<Extension<unknown>>();
detectedExtensions.push(
createPageExtension({
id: `plugin.${pluginId}.page`,
defaultPath: path[0] === '/' ? path.slice(1) : path,
routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
loader: async () =>
route.props.children ? (
<Routes>
<Route path="*" element={routeElement}>
<Route path="*" element={route.props.children} />
</Route>
</Routes>
) : (
routeElement
),
}),
);
detectedExtensions.push(
...Array.from(plugin.getApis()).map(factory =>
createApiExtension({
factory,
}),
),
);
results.push(
createPlugin({
id: plugin.getId(),
extensions: detectedExtensions,
}),
);
},
);
return results;
}
+16
View File
@@ -0,0 +1,16 @@
/*
* 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 { collectLegacyRoutes } from './collectLegacyRoutes';
@@ -0,0 +1,16 @@
/*
* 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 '@testing-library/jest-dom';
@@ -23,7 +23,7 @@ import {
createThemeExtension,
} from '@backstage/frontend-plugin-api';
import { createApp, createInstances } from './createApp';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
import React from 'react';
@@ -244,4 +244,42 @@ describe('createApp', () => {
}
`);
});
it('should deduplicate features keeping the last received one', async () => {
const duplicatedFeatureId = 'test';
const app = createApp({
configLoader: async () => new MockConfigApi({}),
features: [
createPlugin({
id: duplicatedFeatureId,
extensions: [
createPageExtension({
id: 'test.page.first',
defaultPath: '/',
loader: async () => <div>First Page</div>,
}),
],
}),
createPlugin({
id: duplicatedFeatureId,
extensions: [
createPageExtension({
id: 'test.page.last',
defaultPath: '/',
loader: async () => <div>Last Page</div>,
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await waitFor(() =>
expect(screen.queryByText('First Page')).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(screen.getByText('Last Page')).toBeInTheDocument(),
);
});
});
@@ -267,6 +267,29 @@ export function createInstances(options: {
return { coreInstance, instances };
}
function deduplicateFeatures(
allFeatures: (BackstagePlugin | ExtensionOverrides)[],
): (BackstagePlugin | ExtensionOverrides)[] {
// Start by removing duplicates by reference
const features = Array.from(new Set(allFeatures));
// Plugins are deduplicated by ID, last one wins
const seenIds = new Set<string>();
return features
.reverse()
.filter(feature => {
if (feature.$$type !== '@backstage/BackstagePlugin') {
return true;
}
if (seenIds.has(feature.id)) {
return false;
}
seenIds.add(feature.id);
return true;
})
.reverse();
}
/** @public */
export function createApp(options: {
features?: (BackstagePlugin | ExtensionOverrides)[];
@@ -287,13 +310,11 @@ export function createApp(options: {
const discoveredFeatures = getAvailableFeatures(config);
const loadedFeatures = (await options.featureLoader?.({ config })) ?? [];
const allFeatures = Array.from(
new Set([
...discoveredFeatures,
...(options.features ?? []),
...loadedFeatures,
]),
);
const allFeatures = deduplicateFeatures([
...discoveredFeatures,
...loadedFeatures,
...(options.features ?? []),
]);
const { coreInstance } = createInstances({
features: allFeatures,
+19
View File
@@ -3952,6 +3952,24 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/core-compat-api@workspace:^, @backstage/core-compat-api@workspace:packages/core-compat-api":
version: 0.0.0-use.local
resolution: "@backstage/core-compat-api@workspace:packages/core-compat-api"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-puppetdb": "workspace:^"
"@backstage/plugin-stackstorm": "workspace:^"
"@oriflame/backstage-plugin-score-card": ^0.7.0
"@testing-library/jest-dom": ^6.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0 || ^18.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
"@backstage/core-components@^0.13.5, @backstage/core-components@workspace:^, @backstage/core-components@workspace:packages/core-components":
version: 0.0.0-use.local
resolution: "@backstage/core-components@workspace:packages/core-components"
@@ -25239,6 +25257,7 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-compat-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"